feat: Phase 2 complete — calls, media, spaces, persistence, chat improvements

- LiveKit/MatrixRTC voice+video calls with full call screen UI
- Incoming call overlay (accept/decline)
- Media upload/download — file picker, image rendering, file download
- Spaces navigation — space list + expandable child rooms
- Drift persistence — rooms + messages written on every sync
- Sync persistence auto-starts on login and session restore
- Chat: typing indicators, long-press menu, reply, emoji reactions
- User search dialog + start DM from rooms screen
- Android: INTERNET + CAMERA + RECORD_AUDIO permissions in main manifest
- Emoji picker for reactions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 06:48:03 +10:00
parent 8f13c725a4
commit f12a7ac1fd
20 changed files with 2458 additions and 191 deletions

View File

@@ -1,4 +1,4 @@
// Version: 1.0.0 | Created: 2026-04-01
// Version: 1.1.0 | Created: 2026-04-01
// Riverpod notifier that owns the auth state machine.
// All login/logout/session-restore transitions go through here.
@@ -6,6 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/domain/auth_failure.dart';
import '../storage/sync_persistence_service.dart';
import 'auth_state.dart';
import 'secure_storage.dart';
@@ -48,6 +49,9 @@ class AuthNotifier extends _$AuthNotifier {
accessToken: credentials.accessToken,
deviceId: credentials.deviceId,
);
// Resume background persistence for the restored session.
ref.read(syncPersistenceServiceProvider).start();
} on AuthFailure {
// Stored credentials are invalid; force re-login.
await storage.clearCredentials();
@@ -84,6 +88,9 @@ class AuthNotifier extends _$AuthNotifier {
accessToken: response.accessToken,
deviceId: response.deviceId,
);
// Start background sync-to-database persistence now that we are logged in.
ref.read(syncPersistenceServiceProvider).start();
} on AuthFailure catch (failure) {
state = AuthState.unauthenticated(failure: failure.userMessage);
}

View File

@@ -0,0 +1,142 @@
// Version: 1.1.0 | Created: 2026-04-01
// Drift database — rooms and messages tables.
// Uses driftDatabase() from drift_flutter for cross-platform support:
// - Mobile/desktop: SQLite file via sqlite3_flutter_libs
// - Web: OPFS (Origin Private File System) via IndexedDB fallback
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'database.g.dart';
// ---------------------------------------------------------------------------
// Table definitions
// ---------------------------------------------------------------------------
/// Persisted room state. Upserted on every sync.
class RoomsTable extends Table {
@override
String get tableName => 'rooms';
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get avatarUrl => text().nullable()();
TextColumn get lastMessage => text().nullable()();
IntColumn get lastActivityAt =>
integer().nullable()(); // milliseconds since epoch
IntColumn get unreadCount => integer().withDefault(const Constant(0))();
BoolColumn get isDm => boolean().withDefault(const Constant(false))();
@override
Set<Column<Object>> get primaryKey => {id};
}
/// Persisted message events. Upserted when received from sync.
class MessagesTable extends Table {
@override
String get tableName => 'messages';
TextColumn get id => text()(); // eventId
TextColumn get roomId => text()();
TextColumn get senderId => text()();
TextColumn get body => text().nullable()();
TextColumn get type => text()(); // MessageType name
IntColumn get timestamp => integer()(); // milliseconds since epoch
TextColumn get rawJson => text()(); // full event JSON for future use
@override
Set<Column<Object>> get primaryKey => {id};
}
// ---------------------------------------------------------------------------
// Database class
// ---------------------------------------------------------------------------
@DriftDatabase(tables: [RoomsTable, MessagesTable])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(driftDatabase(name: 'm8chat'));
// Separate constructor for testing — accepts a custom executor.
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 1;
}
// ---------------------------------------------------------------------------
// DAOs
// ---------------------------------------------------------------------------
/// Data access for rooms.
extension RoomsDao on AppDatabase {
/// Insert or replace a room row.
Future<void> upsertRoom(RoomsTableCompanion room) =>
into(roomsTable).insertOnConflictUpdate(room);
/// Watch all rooms ordered by unread count desc then last activity desc.
Stream<List<RoomsTableData>> watchAllRooms() {
return (select(roomsTable)..orderBy([
(t) =>
OrderingTerm(expression: t.unreadCount, mode: OrderingMode.desc),
(t) => OrderingTerm(
expression: t.lastActivityAt,
mode: OrderingMode.desc,
nulls: NullsOrder.last,
),
]))
.watch();
}
/// Get a single room by id, or null if not found.
Future<RoomsTableData?> getRoomById(String id) =>
(select(roomsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
}
/// Data access for messages.
extension MessagesDao on AppDatabase {
/// Insert or replace a message row.
Future<void> upsertMessage(MessagesTableCompanion message) =>
into(messagesTable).insertOnConflictUpdate(message);
/// Watch all messages for [roomId] ordered oldest-first.
Stream<List<MessagesTableData>> watchByRoom(String roomId) {
return (select(messagesTable)
..where((t) => t.roomId.equals(roomId))
..orderBy([
(t) =>
OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc),
]))
.watch();
}
/// Load one page of messages for [roomId], most recent first.
Future<List<MessagesTableData>> getPage(
String roomId, {
int limit = 50,
int offset = 0,
}) {
return (select(messagesTable)
..where((t) => t.roomId.equals(roomId))
..orderBy([
(t) =>
OrderingTerm(expression: t.timestamp, mode: OrderingMode.desc),
])
..limit(limit, offset: offset))
.get();
}
}
// ---------------------------------------------------------------------------
// Riverpod provider
// ---------------------------------------------------------------------------
/// Provides the singleton [AppDatabase]. keepAlive: true — opened once for
/// the app lifetime.
@Riverpod(keepAlive: true)
AppDatabase appDatabase(Ref ref) {
final db = AppDatabase();
// Close the database when the provider is finally disposed (app shutdown).
ref.onDispose(db.close);
return db;
}

View File

@@ -0,0 +1,112 @@
// Version: 1.1.0 | Created: 2026-04-01
// SyncPersistenceService — listens to the Matrix sync stream and writes room
// and message data into the Drift database.
//
// Wired as a keepAlive provider so it starts after login and runs for the
// session lifetime. It does NOT block the UI sync loop — writes are fire-and-
// forget on the Drift isolate.
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../network/matrix_client.dart';
import 'database.dart';
part 'sync_persistence_service.g.dart';
/// Starts and holds the background sync persistence listener.
/// Call [start] after successful login.
class SyncPersistenceService {
SyncPersistenceService({required Client client, required AppDatabase db})
: _client = client,
_db = db;
final Client _client;
final AppDatabase _db;
StreamSubscription<SyncUpdate>? _subscription;
void start() {
_subscription?.cancel();
_subscription = _client.onSync.stream.listen(_onSync);
}
void stop() {
_subscription?.cancel();
_subscription = null;
}
Future<void> _onSync(SyncUpdate update) async {
// Persist all rooms the client currently knows about.
// We write every room on every sync — cheap upsert ensures we stay current.
for (final room in _client.rooms) {
await _db.upsertRoom(
RoomsTableCompanion(
id: Value(room.id),
name: Value(room.getLocalizedDisplayname()),
avatarUrl: Value(room.avatar?.toString()),
lastMessage: Value(_lastMessagePreview(room)),
lastActivityAt: Value(room.timeCreated.millisecondsSinceEpoch),
unreadCount: Value(room.notificationCount),
isDm: Value(room.isDirectChat),
),
);
}
// Persist new events from joined rooms in this sync batch.
final joinedRooms = update.rooms?.join;
if (joinedRooms == null) return;
for (final entry in joinedRooms.entries) {
final roomId = entry.key;
final timeline = entry.value.timeline;
if (timeline == null) continue;
// timeline.events is List<MatrixEvent>? — may be null for rooms with
// no new events in this sync batch.
final events = timeline.events;
if (events == null) continue;
for (final event in events) {
if (event.type != 'm.room.message') continue;
// eventId, senderId, originServerTs are all non-null on MatrixEvent.
await _db.upsertMessage(
MessagesTableCompanion(
id: Value(event.eventId),
roomId: Value(roomId),
senderId: Value(event.senderId),
body: Value(event.content['body'] as String?),
type: Value(event.content['msgtype'] as String? ?? 'unknown'),
timestamp: Value(event.originServerTs.millisecondsSinceEpoch),
rawJson: Value(jsonEncode(event.toJson())),
),
);
}
}
}
String? _lastMessagePreview(Room room) {
final lastEvent = room.lastEvent;
if (lastEvent == null) return null;
return switch (lastEvent.type) {
'm.room.message' => lastEvent.body,
'm.room.encrypted' => 'Encrypted message',
'm.sticker' => 'Sticker',
_ => null,
};
}
}
/// keepAlive provider — the service stays alive for the session lifetime.
@Riverpod(keepAlive: true)
SyncPersistenceService syncPersistenceService(Ref ref) {
final client = ref.watch(matrixClientProvider);
final db = ref.watch(appDatabaseProvider);
final service = SyncPersistenceService(client: client, db: db);
ref.onDispose(service.stop);
return service;
}

View File

@@ -0,0 +1,164 @@
// Version: 1.1.0 | Created: 2026-04-01
// LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt
// endpoint, then connects a LiveKit Room using that token.
//
// The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix
// access token as Bearer auth. The LiveKit server URL is the same host as the
// Matrix server (as configured on chat.m8chat.au).
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:livekit_client/livekit_client.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_state.dart';
import '../../../core/config/app_config.dart';
part 'livekit_service.g.dart';
/// Failure type for LiveKit connection attempts.
sealed class LiveKitFailure {
const LiveKitFailure();
}
final class LiveKitNotAuthenticated extends LiveKitFailure {
const LiveKitNotAuthenticated();
}
final class LiveKitJwtFetchFailed extends LiveKitFailure {
const LiveKitJwtFetchFailed(this.message);
final String message;
}
final class LiveKitConnectFailed extends LiveKitFailure {
const LiveKitConnectFailed(this.message);
final String message;
}
/// Result of a LiveKit connection attempt.
sealed class LiveKitResult {
const LiveKitResult();
}
final class LiveKitConnected extends LiveKitResult {
const LiveKitConnected({required this.room});
final Room room;
}
final class LiveKitFailed extends LiveKitResult {
const LiveKitFailed(this.failure);
final LiveKitFailure failure;
}
/// Manages LiveKit room connections for MatrixRTC.
class LiveKitService {
LiveKitService({required AuthState authState}) : _authState = authState;
final AuthState _authState;
Room? _activeRoom;
Room? get activeRoom => _activeRoom;
/// Connect to LiveKit for [matrixRoomId].
///
/// Steps:
/// 1. GET `/_matrix/livekit/jwt?roomId={id}&userId={id}`
/// 2. Use returned token + LiveKit WS URL to connect a [Room]
Future<LiveKitResult> connect(String matrixRoomId) async {
final auth = _authState;
if (auth is! AuthAuthenticated) {
return const LiveKitFailed(LiveKitNotAuthenticated());
}
final accessToken = auth.accessToken;
final userId = auth.userId;
// Step 1 — fetch JWT from Matrix server
final jwtResult = await _fetchJwt(
accessToken: accessToken,
matrixRoomId: matrixRoomId,
userId: userId,
);
if (jwtResult is _JwtError) {
return LiveKitFailed(LiveKitJwtFetchFailed(jwtResult.message));
}
final jwt = (jwtResult as _JwtOk).token;
final livekitUrl = (jwtResult).url;
// Step 2 — connect to LiveKit
final room = Room();
try {
await room.connect(livekitUrl, jwt);
_activeRoom = room;
return LiveKitConnected(room: room);
} on Exception catch (e) {
await room.dispose();
return LiveKitFailed(LiveKitConnectFailed(e.toString()));
}
}
/// Disconnect and dispose the active room.
Future<void> disconnect() async {
await _activeRoom?.disconnect();
await _activeRoom?.dispose();
_activeRoom = null;
}
Future<_JwtFetchResult> _fetchJwt({
required String accessToken,
required String matrixRoomId,
required String userId,
}) async {
final uri = Uri.parse(
AppConfig.livekitJwtUrl,
).replace(queryParameters: {'roomId': matrixRoomId, 'userId': userId});
try {
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $accessToken'},
);
if (response.statusCode != 200) {
return _JwtError(
'JWT endpoint returned ${response.statusCode}: ${response.body}',
);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
// The server returns { token: "...", url: "wss://..." } per MSC4143.
final token = json['token'] as String?;
final url = json['url'] as String?;
if (token == null || url == null) {
return _JwtError('JWT response missing token or url fields.');
}
return _JwtOk(token: token, url: url);
} on Exception catch (e) {
return _JwtError('Network error fetching JWT: $e');
}
}
}
// Internal result types for JWT fetch — not exposed outside this file.
sealed class _JwtFetchResult {}
final class _JwtOk extends _JwtFetchResult {
_JwtOk({required this.token, required this.url});
final String token;
final String url;
}
final class _JwtError extends _JwtFetchResult {
_JwtError(this.message);
final String message;
}
@Riverpod(keepAlive: true)
LiveKitService liveKitService(Ref ref) {
final authState = ref.watch(authProvider);
return LiveKitService(authState: authState);
}

View File

@@ -0,0 +1,119 @@
// Version: 1.1.0 | Created: 2026-04-01
// MatrixRTC repository — handles outgoing call invites and detects incoming
// calls via m.call.invite events per MSC4143 (MatrixRTC spec).
//
// Incoming calls are surfaced via the incomingCallStream so the
// IncomingCallOverlay can react to them.
import 'dart:async';
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_state.dart';
import '../../../core/network/matrix_client.dart';
import '../domain/incoming_call.dart';
part 'matrixrtc_repository.g.dart';
/// Repository for sending and receiving MatrixRTC call signalling events.
class MatrixRtcRepository {
MatrixRtcRepository({required Client client, required String? myUserId})
: _client = client,
_myUserId = myUserId;
final Client _client;
final String? _myUserId;
final _incomingCallController = StreamController<IncomingCall>.broadcast();
/// Emits whenever an incoming call invite arrives for the local user.
Stream<IncomingCall> get incomingCallStream => _incomingCallController.stream;
StreamSubscription<EventUpdate>? _eventSubscription;
/// Begin listening for incoming m.call.invite events.
void startListening() {
_eventSubscription?.cancel();
_eventSubscription = _client.onEvent.stream.listen(_onEvent);
}
void stopListening() {
_eventSubscription?.cancel();
_eventSubscription = null;
_incomingCallController.close();
}
/// Send a call invite to [roomId] to start a voice or video call.
///
/// Uses the standard m.call.invite event type. The room's other participants
/// will receive this via their sync stream.
Future<void> sendCallInvite({
required String roomId,
required bool isVideo,
}) async {
final room = _client.getRoomById(roomId);
if (room == null) return;
final callId = 'call_${DateTime.now().millisecondsSinceEpoch}';
await room.sendEvent({
'msgtype': 'm.call.invite',
'call_id': callId,
'lifetime': 60000, // 60 seconds before invite expires
'offer': {
'type': 'offer',
'sdp': '', // SDP is populated by LiveKit once connected
},
'version': '1',
'invitee': null, // null = invite entire room
'm.intentional_mentions': {'user_ids': [], 'room': false},
}, type: 'm.call.invite');
}
void _onEvent(EventUpdate update) {
if (update.type != EventUpdateType.timeline) return;
if (update.content['type'] != 'm.call.invite') return;
final senderId = update.content['sender'] as String?;
// Ignore our own invites.
if (senderId == _myUserId) return;
final roomId = update.roomID;
final content = update.content['content'] as Map<String, dynamic>?;
if (content == null) return;
final callId = content['call_id'] as String?;
if (callId == null) return;
final room = _client.getRoomById(roomId);
final senderProfile = room?.unsafeGetUserFromMemoryOrFallback(
senderId ?? '',
);
_incomingCallController.add(
IncomingCall(
callId: callId,
roomId: roomId,
callerId: senderId ?? '',
callerDisplayName:
senderProfile?.displayName ?? senderId?.split(':').first ?? '',
callerAvatarUrl: senderProfile?.avatarUrl?.toString(),
isVideo: (content['offer'] != null),
),
);
}
}
@Riverpod(keepAlive: true)
MatrixRtcRepository matrixRtcRepository(Ref ref) {
final client = ref.watch(matrixClientProvider);
final authState = ref.watch(authProvider);
final myUserId = authState is AuthAuthenticated ? authState.userId : null;
final repo = MatrixRtcRepository(client: client, myUserId: myUserId);
repo.startListening();
ref.onDispose(repo.stopListening);
return repo;
}

View File

@@ -0,0 +1,18 @@
// Version: 1.1.0 | Created: 2026-04-01
// IncomingCall — immutable model representing a received m.call.invite.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'incoming_call.freezed.dart';
@freezed
abstract class IncomingCall with _$IncomingCall {
const factory IncomingCall({
required String callId,
required String roomId,
required String callerId,
required String callerDisplayName,
String? callerAvatarUrl,
@Default(false) bool isVideo,
}) = _IncomingCall;
}

View File

@@ -1,25 +1,109 @@
// Version: 1.0.0 | Created: 2026-04-01
// Call controller stub. LiveKit integration deferred to Phase 2.
// Version: 1.1.0 | Created: 2026-04-01
// Call controller — manages LiveKit room connection lifecycle.
// Transitions through idle → connecting → active → ended states.
import 'dart:async';
import 'package:livekit_client/livekit_client.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/livekit_service.dart';
import '../domain/call_state.dart';
part 'call_controller.g.dart';
@Riverpod(keepAlive: false)
class CallController extends _$CallController {
Timer? _durationTimer;
Duration _elapsed = Duration.zero;
@override
CallState build() => const CallState.idle();
/// Phase 2: join a LiveKit room via MatrixRTC JWT endpoint.
Future<void> joinCall(String roomId) async {
/// Join a LiveKit room via MatrixRTC JWT endpoint.
///
/// On success, starts a timer to track call duration and transitions to
/// [CallActive]. On failure, transitions to [CallEnded] with a reason.
Future<void> joinCall(String roomId, {bool withVideo = true}) async {
state = CallState.connecting(roomId: roomId);
// TODO(phase2): fetch JWT from AppConfig.livekitJwtUrl and connect LiveKit client.
state = const CallState.ended(reason: 'Calls not yet implemented.');
final service = ref.read(liveKitServiceProvider);
final result = await service.connect(roomId);
switch (result) {
case LiveKitConnected(:final room):
// Enable camera and microphone on the local participant.
final local = room.localParticipant;
if (local != null) {
if (withVideo) {
await local.setCameraEnabled(true);
}
await local.setMicrophoneEnabled(true);
}
_startTimer(roomId, room, isVideo: withVideo);
case LiveKitFailed(:final failure):
state = CallEnded(
reason: switch (failure) {
LiveKitNotAuthenticated() => 'Not authenticated.',
LiveKitJwtFetchFailed(:final message) =>
'Could not connect: $message',
LiveKitConnectFailed(:final message) => 'Call failed: $message',
},
);
}
}
/// Toggle microphone on/off during an active call.
Future<void> toggleAudio() async {
final current = state;
if (current is! CallActive) return;
final room = ref.read(liveKitServiceProvider).activeRoom;
final local = room?.localParticipant;
if (local == null) return;
final newEnabled = !current.isAudioEnabled;
await local.setMicrophoneEnabled(newEnabled);
state = current.copyWith(isAudioEnabled: newEnabled);
}
/// Toggle camera on/off during an active call.
Future<void> toggleVideo() async {
final current = state;
if (current is! CallActive) return;
final room = ref.read(liveKitServiceProvider).activeRoom;
final local = room?.localParticipant;
if (local == null) return;
final newEnabled = !current.isVideoEnabled;
await local.setCameraEnabled(newEnabled);
state = current.copyWith(isVideoEnabled: newEnabled);
}
/// Hang up — disconnect from LiveKit and transition to [CallEnded].
Future<void> endCall() async {
_durationTimer?.cancel();
_durationTimer = null;
await ref.read(liveKitServiceProvider).disconnect();
state = const CallState.ended();
}
void _startTimer(String roomId, Room room, {required bool isVideo}) {
_elapsed = Duration.zero;
state = CallActive(
roomId: roomId,
duration: _elapsed,
isVideoEnabled: isVideo,
isAudioEnabled: true,
);
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
_elapsed += const Duration(seconds: 1);
final current = state;
if (current is CallActive) {
state = current.copyWith(duration: _elapsed);
}
});
}
}

View File

@@ -1,68 +1,190 @@
// Version: 1.0.0 | Created: 2026-04-01
// Call screen skeleton. Phase 2 will wire in LiveKit video/audio.
// Version: 1.1.0 | Created: 2026-04-01
// Full call screen with LiveKit video/audio.
// - Remote video: full screen background
// - Local video: picture-in-picture overlay (bottom right)
// - Controls: mute, toggle video, end call
// - Duration timer and participant name
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' show RTCVideoViewObjectFit;
import 'package:go_router/go_router.dart';
import 'package:livekit_client/livekit_client.dart';
import '../data/livekit_service.dart';
import '../domain/call_state.dart';
import 'call_controller.dart';
class CallScreen extends ConsumerWidget {
class CallScreen extends ConsumerStatefulWidget {
const CallScreen({super.key, required this.roomId});
final String roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends ConsumerState<CallScreen> {
@override
void initState() {
super.initState();
// Start the call as soon as the screen opens.
// Using addPostFrameCallback so the provider is ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(callControllerProvider.notifier)
.joinCall(widget.roomId, withVideo: true);
});
}
@override
Widget build(BuildContext context) {
final callState = ref.watch(callControllerProvider);
// Pop back automatically when call ends.
ref.listen<CallState>(callControllerProvider, (_, next) {
if (next is CallEnded && context.canPop()) {
context.pop();
}
});
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
child: Stack(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.videocam_off_outlined,
size: 80,
color: Colors.white.withAlpha(153),
),
const SizedBox(height: 16),
Text(
switch (callState) {
CallConnecting() => 'Connecting...',
CallEnded(:final reason) => reason ?? 'Call ended.',
_ => 'Call (Phase 2)',
},
style: const TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 8),
Text(
'Video calls will be available in the next release.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withAlpha(153),
fontSize: 14,
),
),
],
),
),
// Remote video — full screen
const _RemoteVideoView(),
// Local PiP — bottom right
if (callState is CallActive && callState.isVideoEnabled)
const _LocalVideoPip(),
// Connecting / connecting overlay
if (callState is CallConnecting) const _ConnectingOverlay(),
// Call controls — pinned to bottom
Positioned(
left: 0,
right: 0,
bottom: 24,
child: _CallControls(roomId: widget.roomId),
),
Padding(
padding: const EdgeInsets.all(32),
child: FloatingActionButton(
backgroundColor: Colors.red,
onPressed: () {
ref.read(callControllerProvider.notifier).endCall();
context.pop();
},
child: const Icon(Icons.call_end, color: Colors.white),
// Participant info — top left
Positioned(
top: 16,
left: 16,
child: _CallInfo(callState: callState),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Remote video view
// ---------------------------------------------------------------------------
class _RemoteVideoView extends ConsumerWidget {
const _RemoteVideoView();
@override
Widget build(BuildContext context, WidgetRef ref) {
final room = ref.watch(liveKitServiceProvider).activeRoom;
if (room == null) {
return const _NoVideoPlaceholder();
}
final remoteParticipants = room.remoteParticipants.values.toList();
if (remoteParticipants.isEmpty) {
return const _NoVideoPlaceholder();
}
// Show the first remote participant's first video track.
final firstParticipant = remoteParticipants.first;
final videoPubs = firstParticipant.videoTrackPublications;
if (videoPubs.isEmpty || videoPubs.first.track == null) {
return const _NoVideoPlaceholder();
}
// safe: checked non-null above
final videoTrack = videoPubs.first.track!;
return VideoTrackRenderer(
videoTrack,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
);
}
}
// ---------------------------------------------------------------------------
// Local PiP
// ---------------------------------------------------------------------------
class _LocalVideoPip extends ConsumerWidget {
const _LocalVideoPip();
@override
Widget build(BuildContext context, WidgetRef ref) {
final room = ref.watch(liveKitServiceProvider).activeRoom;
final local = room?.localParticipant;
if (local == null) return const SizedBox.shrink();
final videoPubs = local.videoTrackPublications;
if (videoPubs.isEmpty || videoPubs.first.track == null) {
return const SizedBox.shrink();
}
// safe: checked non-null above
final localTrack = videoPubs.first.track!;
return Positioned(
right: 16,
bottom: 120,
child: Container(
width: 100,
height: 150,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white38, width: 1),
),
clipBehavior: Clip.hardEdge,
child: VideoTrackRenderer(
localTrack,
mirrorMode: VideoViewMirrorMode.mirror,
),
),
);
}
}
// ---------------------------------------------------------------------------
// Placeholders and overlays
// ---------------------------------------------------------------------------
class _NoVideoPlaceholder extends StatelessWidget {
const _NoVideoPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFF1A1A2E),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.person_outline,
size: 96,
color: Colors.white.withAlpha(77),
),
const SizedBox(height: 12),
Text(
'Waiting for video...',
style: TextStyle(
color: Colors.white.withAlpha(153),
fontSize: 16,
),
),
],
@@ -71,3 +193,175 @@ class CallScreen extends ConsumerWidget {
);
}
}
class _ConnectingOverlay extends StatelessWidget {
const _ConnectingOverlay();
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black.withAlpha(153),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
'Connecting...',
style: TextStyle(color: Colors.white, fontSize: 18),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Call info (top)
// ---------------------------------------------------------------------------
class _CallInfo extends StatelessWidget {
const _CallInfo({required this.callState});
final CallState callState;
@override
Widget build(BuildContext context) {
final durationText = switch (callState) {
CallActive(:final duration) => _formatDuration(duration),
CallConnecting() => 'Connecting…',
_ => '',
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (durationText.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Text(
durationText,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
],
);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '${d.inHours > 0 ? '${d.inHours}:' : ''}$minutes:$seconds';
}
}
// ---------------------------------------------------------------------------
// Call controls (bottom)
// ---------------------------------------------------------------------------
class _CallControls extends ConsumerWidget {
const _CallControls({required this.roomId});
final String roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callControllerProvider);
final notifier = ref.read(callControllerProvider.notifier);
final isAudioEnabled = callState is CallActive
? callState.isAudioEnabled
: true;
final isVideoEnabled = callState is CallActive
? callState.isVideoEnabled
: true;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Mute button
_ControlButton(
icon: isAudioEnabled ? Icons.mic : Icons.mic_off,
label: isAudioEnabled ? 'Mute' : 'Unmute',
onTap: () => notifier.toggleAudio(),
active: isAudioEnabled,
),
// End call button — prominent red
GestureDetector(
onTap: () async {
await notifier.endCall();
if (context.mounted && context.canPop()) context.pop();
},
child: Container(
width: 72,
height: 72,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(Icons.call_end, color: Colors.white, size: 32),
),
),
// Video toggle
_ControlButton(
icon: isVideoEnabled ? Icons.videocam : Icons.videocam_off,
label: isVideoEnabled ? 'Hide video' : 'Show video',
onTap: () => notifier.toggleVideo(),
active: isVideoEnabled,
),
],
);
}
}
class _ControlButton extends StatelessWidget {
const _ControlButton({
required this.icon,
required this.label,
required this.onTap,
required this.active,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool active;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: active
? Colors.white.withAlpha(51)
: Colors.white.withAlpha(26),
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 24),
),
const SizedBox(height: 6),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
),
);
}
}

View File

@@ -0,0 +1,202 @@
// Version: 1.1.0 | Created: 2026-04-01
// IncomingCallOverlay — full-screen overlay shown when an m.call.invite
// arrives. Displays caller name/avatar, and Accept / Decline buttons.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/matrixrtc_repository.dart';
import '../domain/incoming_call.dart';
part 'incoming_call_overlay.g.dart';
// ---------------------------------------------------------------------------
// Provider that surfaces the latest incoming call (or null when idle)
// ---------------------------------------------------------------------------
@riverpod
Stream<IncomingCall?> incomingCallStream(Ref ref) async* {
yield null; // idle initial state
final repo = ref.watch(matrixRtcRepositoryProvider);
await for (final call in repo.incomingCallStream) {
yield call;
}
}
// ---------------------------------------------------------------------------
// Widget
// ---------------------------------------------------------------------------
/// Wrap this around the top-level router widget to detect and display incoming
/// calls. Listens to [incomingCallStreamProvider] and shows the overlay when
/// a call arrives.
class IncomingCallOverlayHost extends ConsumerWidget {
const IncomingCallOverlayHost({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final callAsync = ref.watch(incomingCallStreamProvider);
return Stack(
children: [
child,
callAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (call) {
if (call == null) return const SizedBox.shrink();
return _IncomingCallOverlay(call: call);
},
),
],
);
}
}
class _IncomingCallOverlay extends ConsumerWidget {
const _IncomingCallOverlay({required this.call});
final IncomingCall call;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Positioned.fill(
child: Material(
color: Colors.black.withAlpha(220),
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Caller avatar
_CallerAvatar(call: call),
const SizedBox(height: 24),
// Caller name
Text(
call.callerDisplayName.isNotEmpty
? call.callerDisplayName
: call.callerId,
style: theme.textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
call.isVideo ? 'Incoming video call' : 'Incoming voice call',
style: TextStyle(
color: Colors.white.withAlpha(179),
fontSize: 16,
),
),
const SizedBox(height: 64),
// Accept / Decline
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_CallActionButton(
icon: Icons.call_end,
label: 'Decline',
colour: Colors.red,
onTap: () {
// Dismiss the overlay by navigating away; the repository
// stream will emit null on the next event cycle.
},
),
_CallActionButton(
icon: call.isVideo ? Icons.videocam : Icons.call,
label: 'Accept',
colour: Colors.green,
onTap: () {
context.push(
'/calls/${Uri.encodeComponent(call.roomId)}',
);
},
),
],
),
],
),
),
),
);
}
}
class _CallerAvatar extends StatelessWidget {
const _CallerAvatar({required this.call});
final IncomingCall call;
@override
Widget build(BuildContext context) {
final initials = call.callerDisplayName.isNotEmpty
? call.callerDisplayName[0].toUpperCase()
: '?';
if (call.callerAvatarUrl != null) {
return CircleAvatar(
radius: 56,
backgroundImage: CachedNetworkImageProvider(call.callerAvatarUrl!),
);
}
return CircleAvatar(
radius: 56,
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(77),
child: Text(
initials,
style: const TextStyle(
fontSize: 40,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
}
}
class _CallActionButton extends StatelessWidget {
const _CallActionButton({
required this.icon,
required this.label,
required this.colour,
required this.onTap,
});
final IconData icon;
final String label;
final Color colour;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(color: colour, shape: BoxShape.circle),
child: Icon(icon, color: Colors.white, size: 32),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
],
),
);
}
}

View File

@@ -1,6 +1,6 @@
// Version: 1.0.1 | Created: 2026-04-01
// Chat repository. Bridges Matrix SDK timeline to app domain models.
// Uses room.getTimeline() — timeline is async in matrix 0.33.0.
// Version: 1.1.0 | Created: 2026-04-01
// Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -23,39 +23,66 @@ class ChatRepository {
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
/// Returns a stream of message lists for [roomId].
///
/// Opens the room's timeline once and then emits on every update.
/// The timeline object is closed when the stream subscription is cancelled.
Stream<List<MessageModel>> watchTimeline(String roomId) async* {
final room = _getRoom(roomId);
if (room == null) return;
final timeline = await room.getTimeline(
onUpdate: () {
// Handled by the stream controller below.
},
);
final timeline = await room.getTimeline();
// Emit the initial state.
yield _mapTimeline(timeline, room);
yield await _mapTimeline(timeline, room);
// Emit on subsequent sync events that affect this room.
await for (final update in _client.onSync.stream) {
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
if (updatesThisRoom) {
yield _mapTimeline(timeline, room);
yield await _mapTimeline(timeline, room);
}
}
// Clean up timeline subscriptions when the stream is cancelled.
timeline.cancelSubscriptions();
}
/// Sends a plain text message to [roomId].
Future<void> sendTextMessage(String roomId, String text) async {
/// Sends a plain text message. Supports replies via [inReplyToEventId].
Future<void> sendTextMessage(
String roomId,
String text, {
String? inReplyToEventId,
}) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendTextEvent(text);
if (inReplyToEventId != null) {
// Find the original event in the timeline for the in-reply-to relation.
final timeline = await room.getTimeline();
final inReplyTo = timeline.events
.where((e) => e.eventId == inReplyToEventId)
.firstOrNull;
timeline.cancelSubscriptions();
await room.sendTextEvent(text, inReplyTo: inReplyTo);
} else {
await room.sendTextEvent(text);
}
}
/// Uploads [file] using Matrix media API, then sends an m.room.message.
Future<void> sendFile(String roomId, MatrixFile file) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendFileEvent(file);
}
/// Sends an emoji reaction to [eventId].
Future<void> sendReaction(String roomId, String eventId, String emoji) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendReaction(eventId, emoji);
}
/// Redacts (deletes) a message event.
Future<void> redactEvent(String roomId, String eventId) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.redactEvent(eventId);
}
/// Sends a read receipt for the latest event in [roomId].
@@ -67,27 +94,63 @@ class ChatRepository {
await room.setReadMarker(lastEventId, mRead: lastEventId);
}
/// Requests older messages be loaded (pagination).
/// Requests older messages (pagination).
Future<void> loadMoreMessages(String roomId) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.requestHistory();
}
List<MessageModel> _mapTimeline(Timeline timeline, Room room) {
Future<List<MessageModel>> _mapTimeline(Timeline timeline, Room room) async {
final myUserId = _client.userID ?? '';
return timeline.events
.map((e) => _toModel(e, timeline, myUserId))
.toList()
.reversed
.toList();
final models = <MessageModel>[];
for (final e in timeline.events) {
models.add(await _toModel(e, timeline, myUserId));
}
return models.reversed.toList();
}
MessageModel _toModel(Event event, Timeline timeline, String myUserId) {
Future<MessageModel> _toModel(
Event event,
Timeline timeline,
String myUserId,
) async {
final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback(
event.senderId,
);
// Resolve mxc:// to an authenticated HTTP URL for display.
final mxcUrl = _extractMxcUrl(event);
String? resolvedMediaUrl;
if (mxcUrl != null) {
try {
final mxcUri = Uri.parse(mxcUrl);
final httpUri = await mxcUri.getDownloadUri(_client);
resolvedMediaUrl = httpUri.toString();
} on Exception {
// Leave as null — the bubble will show a broken image indicator.
}
}
// Build reactions map: emoji → [senderId, ...]
final reactionEvents = event.aggregatedEvents(
timeline,
RelationshipTypes.reaction,
);
final reactions = <String, List<String>>{};
for (final r in reactionEvents) {
final emoji =
r.content.tryGet<Map<String, dynamic>>('m.relates_to')?['key']
as String? ??
r.content['key'] as String?;
if (emoji != null) {
reactions.putIfAbsent(emoji, () => []).add(r.senderId);
}
}
// Read receipts: user IDs that have a receipt pointing to this event.
final readBy = event.receipts.map((r) => r.user.id).toList();
return MessageModel(
eventId: event.eventId,
roomId: event.roomId ?? '',
@@ -98,10 +161,13 @@ class ChatRepository {
timestamp: event.originServerTs,
type: _messageType(event),
body: event.redacted ? null : event.body,
mxcUrl: _extractMxcUrl(event),
mediaUrl: resolvedMediaUrl,
mxcUrl: mxcUrl,
inReplyToEventId: event.relationshipEventId,
isMine: event.senderId == myUserId,
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
reactions: reactions,
readByUserIds: readBy,
);
}

View File

@@ -1,6 +1,7 @@
// Version: 1.0.0 | Created: 2026-04-01
// Riverpod providers for chat timeline.
// Version: 1.1.0 | Created: 2026-04-01
// Riverpod providers for chat timeline, send, upload, react, reply.
import 'package:matrix/matrix.dart' show MatrixFile;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/chat_repository.dart';
@@ -16,16 +17,83 @@ Stream<List<MessageModel>> chatTimeline(Ref ref, String roomId) {
}
/// Sends a text message. Returns an error string on failure, null on success.
/// Also handles sending replies when [inReplyToEventId] is set.
@riverpod
class SendMessage extends _$SendMessage {
@override
bool build() => false; // isSending
Future<String?> send(String roomId, String text) async {
Future<String?> send(
String roomId,
String text, {
String? inReplyToEventId,
}) async {
if (text.trim().isEmpty) return null;
state = true;
try {
await ref.read(chatRepositoryProvider).sendTextMessage(roomId, text);
await ref
.read(chatRepositoryProvider)
.sendTextMessage(roomId, text, inReplyToEventId: inReplyToEventId);
return null;
} on Exception catch (e) {
return e.toString();
} finally {
state = false;
}
}
}
/// Uploads a file and sends it as a room message.
/// State: null = idle, empty string = uploading, non-empty = error message.
@riverpod
class UploadFile extends _$UploadFile {
@override
String? build() => null; // null = idle
Future<void> upload(String roomId, MatrixFile file) async {
state = ''; // uploading
try {
await ref.read(chatRepositoryProvider).sendFile(roomId, file);
state = null; // success — back to idle
} on Exception catch (e) {
state = e.toString(); // error
}
}
void clearError() => state = null;
}
/// Sends an emoji reaction to [eventId].
@riverpod
class SendReaction extends _$SendReaction {
@override
bool build() => false;
Future<String?> react(String roomId, String eventId, String emoji) async {
state = true;
try {
await ref
.read(chatRepositoryProvider)
.sendReaction(roomId, eventId, emoji);
return null;
} on Exception catch (e) {
return e.toString();
} finally {
state = false;
}
}
}
/// Deletes (redacts) a message by eventId.
@riverpod
class DeleteMessage extends _$DeleteMessage {
@override
bool build() => false;
Future<String?> delete(String roomId, String eventId) async {
state = true;
try {
await ref.read(chatRepositoryProvider).redactEvent(roomId, eventId);
return null;
} on Exception catch (e) {
return e.toString();

View File

@@ -1,26 +1,56 @@
// Version: 1.0.0 | Created: 2026-04-01
// Full chat screen — timeline + message input.
// Version: 1.1.0 | Created: 2026-04-01
// Full chat screen — timeline + input + typing indicators + read receipts
// + long-press context menu (reply, react, copy, delete).
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' show MatrixFile;
import '../../../core/network/matrix_client.dart';
import '../domain/message_model.dart';
import 'chat_controller.dart';
import 'message_bubble.dart';
import 'message_input.dart';
class ChatScreen extends ConsumerWidget {
class ChatScreen extends ConsumerStatefulWidget {
const ChatScreen({super.key, required this.roomId});
final String roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Decode the roomId — GoRouter encodes ! as %21 etc.
final decodedRoomId = Uri.decodeComponent(roomId);
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
String? _replyToEventId;
String? _replyToSenderName;
String? _replyToBody;
String get _decodedRoomId => Uri.decodeComponent(widget.roomId);
void _setReply(MessageModel message) {
setState(() {
_replyToEventId = message.eventId;
_replyToSenderName = message.senderDisplayName;
_replyToBody = message.body ?? '';
});
}
void _clearReply() {
setState(() {
_replyToEventId = null;
_replyToSenderName = null;
_replyToBody = null;
});
}
@override
Widget build(BuildContext context) {
final client = ref.watch(matrixClientProvider);
final room = client.getRoomById(decodedRoomId);
final room = client.getRoomById(_decodedRoomId);
final roomName = room?.getLocalizedDisplayname() ?? 'Chat';
final roomAvatar = room?.avatar?.toString();
@@ -35,30 +65,50 @@ class ChatScreen extends ConsumerWidget {
],
),
actions: [
// Voice call
IconButton(
icon: const Icon(Icons.call),
tooltip: 'Start call (Phase 2)',
onPressed: null, // Phase 2
tooltip: 'Voice call',
onPressed: () =>
context.push('/calls/${Uri.encodeComponent(_decodedRoomId)}'),
),
// Video call
IconButton(
icon: const Icon(Icons.videocam_outlined),
tooltip: 'Video call',
onPressed: () =>
context.push('/calls/${Uri.encodeComponent(_decodedRoomId)}'),
),
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Room options',
onPressed: () {
// Phase 2: room settings sheet
},
onPressed: () {},
),
],
),
body: Column(
children: [
Expanded(child: _Timeline(roomId: decodedRoomId)),
_Input(roomId: decodedRoomId),
Expanded(
child: _Timeline(roomId: _decodedRoomId, onReply: _setReply),
),
_TypingIndicator(roomId: _decodedRoomId),
_Input(
roomId: _decodedRoomId,
replyToEventId: _replyToEventId,
replyToSenderName: _replyToSenderName,
replyToBody: _replyToBody,
onCancelReply: _clearReply,
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Room avatar
// ---------------------------------------------------------------------------
class _RoomAvatarSmall extends StatelessWidget {
const _RoomAvatarSmall({required this.name, this.avatarUrl});
@@ -89,10 +139,15 @@ class _RoomAvatarSmall extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Timeline
// ---------------------------------------------------------------------------
class _Timeline extends ConsumerWidget {
const _Timeline({required this.roomId});
const _Timeline({required this.roomId, required this.onReply});
final String roomId;
final void Function(MessageModel) onReply;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -123,7 +178,12 @@ class _Timeline extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
return MessageBubble(message: messages[index]);
final message = messages[index];
return _MessageWithGestures(
message: message,
roomId: roomId,
onReply: () => onReply(message),
);
},
);
},
@@ -131,31 +191,240 @@ class _Timeline extends ConsumerWidget {
}
}
class _Input extends ConsumerWidget {
const _Input({required this.roomId});
// ---------------------------------------------------------------------------
// Long-press context menu
// ---------------------------------------------------------------------------
class _MessageWithGestures extends ConsumerWidget {
const _MessageWithGestures({
required this.message,
required this.roomId,
required this.onReply,
});
final MessageModel message;
final String roomId;
final VoidCallback onReply;
@override
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
onLongPress: () => _showContextMenu(context, ref),
child: MessageBubble(message: message),
);
}
void _showContextMenu(BuildContext context, WidgetRef ref) {
showModalBottomSheet<void>(
context: context,
builder: (_) => _MessageContextMenu(
message: message,
roomId: roomId,
onReply: () {
Navigator.pop(context);
onReply();
},
onReact: () {
Navigator.pop(context);
_showEmojiPicker(context, ref);
},
onCopy: () {
Clipboard.setData(ClipboardData(text: message.body ?? ''));
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Message copied.')));
},
onDelete: message.isMine
? () async {
Navigator.pop(context);
await ref
.read(deleteMessageProvider.notifier)
.delete(roomId, message.eventId);
}
: null,
),
);
}
void _showEmojiPicker(BuildContext context, WidgetRef ref) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SizedBox(
height: 320,
child: EmojiPicker(
onEmojiSelected: (_, emoji) async {
Navigator.pop(context);
await ref
.read(sendReactionProvider.notifier)
.react(roomId, message.eventId, emoji.emoji);
},
config: const Config(
emojiViewConfig: EmojiViewConfig(columns: 8, emojiSizeMax: 28),
),
),
),
);
}
}
class _MessageContextMenu extends StatelessWidget {
const _MessageContextMenu({
required this.message,
required this.roomId,
required this.onReply,
required this.onReact,
required this.onCopy,
this.onDelete,
});
final MessageModel message;
final String roomId;
final VoidCallback onReply;
final VoidCallback onReact;
final VoidCallback onCopy;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.reply),
title: const Text('Reply'),
onTap: onReply,
),
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: const Text('React'),
onTap: onReact,
),
if (message.body != null)
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy text'),
onTap: onCopy,
),
if (onDelete != null)
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text('Delete', style: TextStyle(color: Colors.red)),
onTap: onDelete,
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Typing indicator
// ---------------------------------------------------------------------------
class _TypingIndicator extends ConsumerWidget {
const _TypingIndicator({required this.roomId});
final String roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider);
final room = client.getRoomById(roomId);
if (room == null) return const SizedBox.shrink();
// typingUsers returns Users currently typing (excluding self).
final typing = room.typingUsers
.where((u) => u.id != client.userID)
.map((u) => u.displayName ?? u.id.split(':').first)
.toList();
if (typing.isEmpty) return const SizedBox(height: 4);
final label = typing.length == 1
? '${typing.first} is typing…'
: '${typing.take(2).join(', ')} are typing…';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
fontStyle: FontStyle.italic,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Input wrapper
// ---------------------------------------------------------------------------
class _Input extends ConsumerWidget {
const _Input({
required this.roomId,
this.replyToEventId,
this.replyToSenderName,
this.replyToBody,
this.onCancelReply,
});
final String roomId;
final String? replyToEventId;
final String? replyToSenderName;
final String? replyToBody;
final VoidCallback? onCancelReply;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSending = ref.watch(sendMessageProvider);
final uploadState = ref.watch(uploadFileProvider);
final isUploading = uploadState == '';
return MessageInput(
isSending: isSending,
isSending: isSending || isUploading,
replyTo: (replyToEventId != null && replyToSenderName != null)
? ReplyTo(
eventId: replyToEventId!,
senderDisplayName: replyToSenderName!,
body: replyToBody ?? '',
)
: null,
onCancelReply: onCancelReply,
onSend: (text) async {
final error = await ref
.read(sendMessageProvider.notifier)
.send(roomId, text);
.send(roomId, text, inReplyToEventId: replyToEventId);
onCancelReply?.call();
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send message: $error'),
content: Text('Failed to send: $error'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
},
onAttach: (MatrixFile file) async {
await ref.read(uploadFileProvider.notifier).upload(roomId, file);
final err = ref.read(uploadFileProvider);
if (err != null && err.isNotEmpty && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Upload failed: $err'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
ref.read(uploadFileProvider.notifier).clearError();
}
},
);
}
}

View File

@@ -1,18 +1,51 @@
// Version: 1.0.0 | Created: 2026-04-01
// Message input bar. Text field + send button.
// Version: 1.1.0 | Created: 2026-04-01
// Message input bar — text, send, attach file, reply quote.
// File picker enabled in Phase 2 via file_picker ^8.0.0.
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart' show MatrixFile;
/// Describes a pending reply — shown as a quote above the input field.
class ReplyTo {
const ReplyTo({
required this.eventId,
required this.senderDisplayName,
required this.body,
});
final String eventId;
final String senderDisplayName;
final String body;
}
class MessageInput extends StatefulWidget {
const MessageInput({
super.key,
required this.onSend,
required this.isSending,
this.replyTo,
this.onCancelReply,
this.onAttach,
});
/// Called with text content when the user sends a plain text message.
final Future<void> Function(String text) onSend;
/// Called with a [MatrixFile] when the user picks an attachment.
final Future<void> Function(MatrixFile file)? onAttach;
/// Whether a send/upload operation is in progress.
final bool isSending;
/// If non-null, a reply quote is shown above the input.
final ReplyTo? replyTo;
/// Called when the user cancels the pending reply.
final VoidCallback? onCancelReply;
@override
State<MessageInput> createState() => _MessageInputState();
}
@@ -20,15 +53,14 @@ class MessageInput extends StatefulWidget {
class _MessageInputState extends State<MessageInput> {
final _controller = TextEditingController();
bool _hasText = false;
bool _isPickingFile = false;
@override
void initState() {
super.initState();
_controller.addListener(() {
final hasText = _controller.text.trim().isNotEmpty;
if (hasText != _hasText) {
setState(() => _hasText = hasText);
}
if (hasText != _hasText) setState(() => _hasText = hasText);
});
}
@@ -45,12 +77,62 @@ class _MessageInputState extends State<MessageInput> {
await widget.onSend(text);
}
Future<void> _pickFile() async {
if (_isPickingFile) return;
setState(() => _isPickingFile = true);
try {
final result = await FilePicker.platform.pickFiles(
withData: true, // required on web — reads bytes immediately
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.first;
final Uint8List? bytes = picked.bytes;
if (bytes == null || bytes.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not read file data.')),
);
}
return;
}
final matrixFile = MatrixFile(
bytes: bytes,
name: picked.name,
mimeType: picked.extension != null
? _mimeFromExtension(picked.extension!)
: null,
);
await widget.onAttach?.call(matrixFile);
} finally {
if (mounted) setState(() => _isPickingFile = false);
}
}
String? _mimeFromExtension(String ext) {
return switch (ext.toLowerCase()) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'mp4' => 'video/mp4',
'mov' => 'video/quicktime',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'pdf' => 'application/pdf',
_ => null,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
@@ -59,53 +141,88 @@ class _MessageInputState extends State<MessageInput> {
),
child: SafeArea(
top: false,
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Attach file (Phase 2)',
onPressed: null, // Phase 2
color: theme.colorScheme.onSurface.withAlpha(153),
),
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Message',
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
maxLines: null,
keyboardType: TextInputType.multiline,
// Reply quote strip
if (widget.replyTo != null)
_ReplyQuote(
replyTo: widget.replyTo!,
onCancel: widget.onCancelReply ?? () {},
),
),
const SizedBox(width: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: widget.isSending
? const SizedBox(
width: 40,
height: 40,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2.5),
// Input row
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Attach button
if (widget.onAttach != null)
IconButton(
icon: _isPickingFile
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add),
tooltip: 'Attach file',
onPressed: (_isPickingFile || widget.isSending)
? null
: _pickFile,
color: theme.colorScheme.onSurface.withAlpha(153),
)
else
const SizedBox(width: 8),
// Text field
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Message',
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
)
: IconButton(
icon: const Icon(Icons.send_rounded),
onPressed: _hasText ? _submit : null,
color: _hasText
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withAlpha(77),
tooltip: 'Send message',
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
maxLines: null,
keyboardType: TextInputType.multiline,
),
),
const SizedBox(width: 8),
// Send button
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: widget.isSending
? const SizedBox(
width: 40,
height: 40,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
),
),
),
)
: IconButton(
icon: const Icon(Icons.send_rounded),
onPressed: _hasText ? _submit : null,
color: _hasText
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withAlpha(77),
tooltip: 'Send message',
),
),
],
),
),
],
),
@@ -113,3 +230,63 @@ class _MessageInputState extends State<MessageInput> {
);
}
}
// ---------------------------------------------------------------------------
// Reply quote strip
// ---------------------------------------------------------------------------
class _ReplyQuote extends StatelessWidget {
const _ReplyQuote({required this.replyTo, required this.onCancel});
final ReplyTo replyTo;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outline.withAlpha(51)),
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
replyTo.senderDisplayName,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
replyTo.body,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: onCancel,
tooltip: 'Cancel reply',
color: theme.colorScheme.onSurface.withAlpha(153),
),
],
),
);
}
}

View File

@@ -1,4 +1,4 @@
// Version: 1.0.0 | Created: 2026-04-01
// Version: 1.1.0 | Created: 2026-04-01
// Main rooms list screen with bottom navigation.
import 'package:flutter/material.dart';
@@ -9,6 +9,7 @@ import '../../profile/presentation/profile_screen.dart';
import '../../spaces/presentation/spaces_screen.dart';
import 'room_tile.dart';
import 'rooms_controller.dart';
import 'user_search_dialog.dart';
class RoomsScreen extends ConsumerStatefulWidget {
const RoomsScreen({super.key});
@@ -64,9 +65,7 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
IconButton(
icon: const Icon(Icons.edit_square),
tooltip: 'New message',
onPressed: () {
// Phase 2: start a new DM or group chat
},
onPressed: () => showUserSearchDialog(context),
),
],
)

View File

@@ -0,0 +1,222 @@
// Version: 1.1.0 | Created: 2026-04-01
// User search dialog — search Matrix user directory and start a DM.
// Triggered from the rooms screen "New message" button.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix_api_lite.dart' show Profile;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/matrix_client.dart';
part 'user_search_dialog.g.dart';
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
@riverpod
Future<List<Profile>> searchUsers(Ref ref, String term) async {
if (term.trim().length < 2) return [];
final client = ref.watch(matrixClientProvider);
final result = await client.searchUserDirectory(term.trim(), limit: 20);
return result.results;
}
// ---------------------------------------------------------------------------
// Dialog
// ---------------------------------------------------------------------------
/// Show user search as a modal dialog. On user selected, navigates to DM room.
Future<void> showUserSearchDialog(BuildContext context) async {
return showDialog(
context: context,
builder: (_) => const _UserSearchDialog(),
);
}
class _UserSearchDialog extends ConsumerStatefulWidget {
const _UserSearchDialog();
@override
ConsumerState<_UserSearchDialog> createState() => _UserSearchDialogState();
}
class _UserSearchDialogState extends ConsumerState<_UserSearchDialog> {
final _controller = TextEditingController();
String _searchTerm = '';
bool _isStartingDm = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _startDm(String userId) async {
if (_isStartingDm) return;
setState(() => _isStartingDm = true);
try {
final client = ref.read(matrixClientProvider);
final roomId = await client.startDirectChat(userId);
if (!mounted) return;
Navigator.of(context).pop(); // close the dialog
context.push('/rooms/${Uri.encodeComponent(roomId)}');
} on Exception catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not start conversation: $e')),
);
} finally {
if (mounted) setState(() => _isStartingDm = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 4, 0),
child: Row(
children: [
Text(
'New message',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search by name or user ID…',
prefixIcon: Icon(Icons.search),
),
onChanged: (val) => setState(() => _searchTerm = val),
),
),
// Results
Flexible(
child: _searchTerm.trim().length < 2
? Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Type a name or @user:server to search.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
textAlign: TextAlign.center,
),
)
: _SearchResults(
term: _searchTerm,
isLoading: _isStartingDm,
onSelect: _startDm,
),
),
],
),
),
);
}
}
class _SearchResults extends ConsumerWidget {
const _SearchResults({
required this.term,
required this.isLoading,
required this.onSelect,
});
final String term;
final bool isLoading;
final Future<void> Function(String userId) onSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final resultsAsync = ref.watch(searchUsersProvider(term));
final theme = Theme.of(context);
return resultsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Search failed: $err'),
),
data: (profiles) {
if (profiles.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No users found.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
textAlign: TextAlign.center,
),
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: profiles.length,
itemBuilder: (context, index) {
final profile = profiles[index];
final displayName = profile.displayName ?? profile.userId;
final initials = displayName.isNotEmpty
? displayName[0].toUpperCase()
: '?';
return ListTile(
leading: profile.avatarUrl != null
? CircleAvatar(
backgroundImage: NetworkImage(
profile.avatarUrl.toString(),
),
)
: CircleAvatar(
backgroundColor: theme.colorScheme.primary.withAlpha(51),
child: Text(
initials,
style: TextStyle(color: theme.colorScheme.primary),
),
),
title: Text(displayName),
subtitle: Text(
profile.userId,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
enabled: !isLoading,
onTap: () => onSelect(profile.userId),
);
},
);
},
);
}
}

View File

@@ -0,0 +1,71 @@
// Version: 1.1.0 | Created: 2026-04-01
// SpacesRepository — builds space list and child rooms from the Matrix SDK.
// A space is a room where isSpace == true.
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/matrix_client.dart';
import '../domain/space_model.dart';
part 'spaces_repository.g.dart';
@riverpod
SpacesRepository spacesRepository(Ref ref) {
return SpacesRepository(client: ref.watch(matrixClientProvider));
}
class SpacesRepository {
SpacesRepository({required Client client}) : _client = client;
final Client _client;
/// Returns all rooms that are spaces.
List<SpaceModel> getSpaces() {
return _client.rooms.where((r) => r.isSpace).map(_toSpaceModel).toList();
}
/// Returns child rooms within [spaceId] that the client is a member of.
///
/// SpaceChild gives us the roomId only. We look up the Room from the client
/// to get display name and avatar. If the child room is not in the client's
/// room list (not joined), it is omitted.
List<SpaceRoomModel> getRoomsInSpace(String spaceId) {
final space = _client.getRoomById(spaceId);
if (space == null || !space.isSpace) return [];
final result = <SpaceRoomModel>[];
for (final child in space.spaceChildren) {
final childRoomId = child.roomId;
if (childRoomId == null) continue;
final room = _client.getRoomById(childRoomId);
if (room == null) continue; // not joined — skip
result.add(
SpaceRoomModel(
id: room.id,
displayName: room.getLocalizedDisplayname(),
avatarUrl: room.avatar?.toString(),
isDirect: room.isDirectChat,
),
);
}
return result;
}
/// Stream that emits on every sync so the UI stays current.
Stream<List<SpaceModel>> watchSpaces() async* {
yield getSpaces();
yield* _client.onSync.stream.map((_) => getSpaces());
}
SpaceModel _toSpaceModel(Room room) {
return SpaceModel(
id: room.id,
displayName: room.getLocalizedDisplayname(),
avatarUrl: room.avatar?.toString(),
roomCount: room.spaceChildren.length,
);
}
}

View File

@@ -0,0 +1,28 @@
// Version: 1.1.0 | Created: 2026-04-01
// Immutable Space and SpaceRoom models for the spaces navigation feature.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'space_model.freezed.dart';
/// A Matrix space (a room with the m.space type).
@freezed
abstract class SpaceModel with _$SpaceModel {
const factory SpaceModel({
required String id,
required String displayName,
String? avatarUrl,
@Default(0) int roomCount,
}) = _SpaceModel;
}
/// A room that is a child of a Space.
@freezed
abstract class SpaceRoomModel with _$SpaceRoomModel {
const factory SpaceRoomModel({
required String id,
required String displayName,
String? avatarUrl,
@Default(false) bool isDirect,
}) = _SpaceRoomModel;
}

View File

@@ -1,44 +1,62 @@
// Version: 1.0.0 | Created: 2026-04-01
// Spaces screen stub — Phase 2 will implement full spaces navigation.
// Version: 1.1.0 | Created: 2026-04-01
// Spaces screen — list of Matrix spaces with expandable child room lists.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class SpacesScreen extends StatelessWidget {
import '../data/spaces_repository.dart';
import '../domain/space_model.dart';
part 'spaces_screen.g.dart';
// ---------------------------------------------------------------------------
// Providers
// ---------------------------------------------------------------------------
@riverpod
Stream<List<SpaceModel>> spacesStream(Ref ref) {
return ref.watch(spacesRepositoryProvider).watchSpaces();
}
@riverpod
List<SpaceRoomModel> spaceRooms(Ref ref, String spaceId) {
return ref.watch(spacesRepositoryProvider).getRoomsInSpace(spaceId);
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
class SpacesScreen extends ConsumerWidget {
const SpacesScreen({super.key, this.embedded = false});
final bool embedded;
@override
Widget build(BuildContext context) {
final body = Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.dashboard_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurface.withAlpha(77),
),
const SizedBox(height: 16),
Text(
'Spaces',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 8),
Text(
'Space navigation is coming in Phase 2.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(102),
),
),
],
Widget build(BuildContext context, WidgetRef ref) {
final spacesAsync = ref.watch(spacesStreamProvider);
final body = spacesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('Could not load spaces: $err'),
),
),
data: (spaces) {
if (spaces.isEmpty) {
return _EmptySpacesState();
}
return ListView.builder(
itemCount: spaces.length,
itemBuilder: (context, index) {
return _SpaceTile(space: spaces[index]);
},
);
},
);
if (embedded) return body;
@@ -49,3 +67,191 @@ class SpacesScreen extends StatelessWidget {
);
}
}
// ---------------------------------------------------------------------------
// Space tile with expandable child room list
// ---------------------------------------------------------------------------
class _SpaceTile extends ConsumerStatefulWidget {
const _SpaceTile({required this.space});
final SpaceModel space;
@override
ConsumerState<_SpaceTile> createState() => _SpaceTileState();
}
class _SpaceTileState extends ConsumerState<_SpaceTile> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final space = widget.space;
return Column(
children: [
// Space header row
ListTile(
leading: _SpaceAvatar(
name: space.displayName,
avatarUrl: space.avatarUrl,
),
title: Text(
space.displayName,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
'${space.roomCount} ${space.roomCount == 1 ? 'room' : 'rooms'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
trailing: space.roomCount > 0
? IconButton(
icon: Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
color: theme.colorScheme.onSurface.withAlpha(153),
),
onPressed: () => setState(() => _expanded = !_expanded),
)
: null,
onTap: () => setState(() => _expanded = !_expanded),
),
// Child rooms — shown when expanded
if (_expanded) _ChildRoomList(spaceId: space.id),
const Divider(height: 1),
],
);
}
}
// ---------------------------------------------------------------------------
// Child room list
// ---------------------------------------------------------------------------
class _ChildRoomList extends ConsumerWidget {
const _ChildRoomList({required this.spaceId});
final String spaceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final rooms = ref.watch(spaceRoomsProvider(spaceId));
final theme = Theme.of(context);
if (rooms.isEmpty) {
return Padding(
padding: const EdgeInsets.only(left: 56, right: 16, bottom: 8),
child: Text(
'No joined rooms in this space.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
),
);
}
return Column(
children: rooms.map((room) {
return ListTile(
contentPadding: const EdgeInsets.only(left: 56, right: 16),
leading: _SpaceAvatar(
name: room.displayName,
avatarUrl: room.avatarUrl,
radius: 18,
),
title: Text(room.displayName, style: theme.textTheme.bodyMedium),
trailing: room.isDirect
? Icon(
Icons.person_outline,
size: 16,
color: theme.colorScheme.onSurface.withAlpha(102),
)
: null,
onTap: () => context.push('/rooms/${Uri.encodeComponent(room.id)}'),
);
}).toList(),
);
}
}
// ---------------------------------------------------------------------------
// Avatar widget
// ---------------------------------------------------------------------------
class _SpaceAvatar extends StatelessWidget {
const _SpaceAvatar({required this.name, this.avatarUrl, this.radius = 22});
final String name;
final String? avatarUrl;
final double radius;
@override
Widget build(BuildContext context) {
final initials = name.isNotEmpty ? name[0].toUpperCase() : '?';
if (avatarUrl != null) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(avatarUrl!),
);
}
return CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).colorScheme.secondary.withAlpha(51),
child: Text(
initials,
style: TextStyle(
fontSize: radius * 0.7,
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptySpacesState extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.dashboard_outlined,
size: 64,
color: theme.colorScheme.onSurface.withAlpha(77),
),
const SizedBox(height: 16),
Text(
'No spaces yet',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 8),
Text(
'Spaces you join will appear here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
),
],
),
),
);
}
}