From f12a7ac1fd8a0407ae99af8d77f0fd7e21e2e415 Mon Sep 17 00:00:00 2001 From: help4bis Date: Thu, 2 Apr 2026 06:48:03 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20complete=20=E2=80=94=20call?= =?UTF-8?q?s,=20media,=20spaces,=20persistence,=20chat=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- android/app/src/main/AndroidManifest.xml | 12 +- lib/core/auth/auth_notifier.dart | 9 +- lib/core/storage/database.dart | 142 +++++++ .../storage/sync_persistence_service.dart | 112 +++++ lib/features/calls/data/livekit_service.dart | 164 ++++++++ .../calls/data/matrixrtc_repository.dart | 119 ++++++ lib/features/calls/domain/incoming_call.dart | 18 + .../calls/presentation/call_controller.dart | 96 ++++- .../calls/presentation/call_screen.dart | 384 ++++++++++++++++-- .../presentation/incoming_call_overlay.dart | 202 +++++++++ lib/features/chat/data/chat_repository.dart | 122 ++++-- .../chat/presentation/chat_controller.dart | 76 +++- .../chat/presentation/chat_screen.dart | 313 +++++++++++++- .../chat/presentation/message_input.dart | 273 ++++++++++--- .../rooms/presentation/rooms_screen.dart | 7 +- .../presentation/user_search_dialog.dart | 222 ++++++++++ .../spaces/data/spaces_repository.dart | 71 ++++ lib/features/spaces/domain/space_model.dart | 28 ++ .../spaces/presentation/spaces_screen.dart | 268 ++++++++++-- pubspec.yaml | 11 +- 20 files changed, 2458 insertions(+), 191 deletions(-) create mode 100644 lib/core/storage/database.dart create mode 100644 lib/core/storage/sync_persistence_service.dart create mode 100644 lib/features/calls/data/livekit_service.dart create mode 100644 lib/features/calls/data/matrixrtc_repository.dart create mode 100644 lib/features/calls/domain/incoming_call.dart create mode 100644 lib/features/calls/presentation/incoming_call_overlay.dart create mode 100644 lib/features/rooms/presentation/user_search_dialog.dart create mode 100644 lib/features/spaces/data/spaces_repository.dart create mode 100644 lib/features/spaces/domain/space_model.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0e8e965..2cb5f45 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,14 @@ + + + + + + + + + + '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> 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> 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 upsertRoom(RoomsTableCompanion room) => + into(roomsTable).insertOnConflictUpdate(room); + + /// Watch all rooms ordered by unread count desc then last activity desc. + Stream> 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 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 upsertMessage(MessagesTableCompanion message) => + into(messagesTable).insertOnConflictUpdate(message); + + /// Watch all messages for [roomId] ordered oldest-first. + Stream> 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> 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; +} diff --git a/lib/core/storage/sync_persistence_service.dart b/lib/core/storage/sync_persistence_service.dart new file mode 100644 index 0000000..d00179e --- /dev/null +++ b/lib/core/storage/sync_persistence_service.dart @@ -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? _subscription; + + void start() { + _subscription?.cancel(); + _subscription = _client.onSync.stream.listen(_onSync); + } + + void stop() { + _subscription?.cancel(); + _subscription = null; + } + + Future _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? — 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; +} diff --git a/lib/features/calls/data/livekit_service.dart b/lib/features/calls/data/livekit_service.dart new file mode 100644 index 0000000..a62758d --- /dev/null +++ b/lib/features/calls/data/livekit_service.dart @@ -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 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 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; + // 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); +} diff --git a/lib/features/calls/data/matrixrtc_repository.dart b/lib/features/calls/data/matrixrtc_repository.dart new file mode 100644 index 0000000..fae956a --- /dev/null +++ b/lib/features/calls/data/matrixrtc_repository.dart @@ -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.broadcast(); + + /// Emits whenever an incoming call invite arrives for the local user. + Stream get incomingCallStream => _incomingCallController.stream; + + StreamSubscription? _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 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?; + 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; +} diff --git a/lib/features/calls/domain/incoming_call.dart b/lib/features/calls/domain/incoming_call.dart new file mode 100644 index 0000000..2bcaa2b --- /dev/null +++ b/lib/features/calls/domain/incoming_call.dart @@ -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; +} diff --git a/lib/features/calls/presentation/call_controller.dart b/lib/features/calls/presentation/call_controller.dart index dc5a266..58a6cc7 100644 --- a/lib/features/calls/presentation/call_controller.dart +++ b/lib/features/calls/presentation/call_controller.dart @@ -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 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 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 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 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 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); + } + }); + } } diff --git a/lib/features/calls/presentation/call_screen.dart b/lib/features/calls/presentation/call_screen.dart index c03b015..580566e 100644 --- a/lib/features/calls/presentation/call_screen.dart +++ b/lib/features/calls/presentation/call_screen.dart @@ -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 createState() => _CallScreenState(); +} + +class _CallScreenState extends ConsumerState { + @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(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), + ), + ], + ), + ); + } +} diff --git a/lib/features/calls/presentation/incoming_call_overlay.dart b/lib/features/calls/presentation/incoming_call_overlay.dart new file mode 100644 index 0000000..61f0f4e --- /dev/null +++ b/lib/features/calls/presentation/incoming_call_overlay.dart @@ -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 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/chat/data/chat_repository.dart b/lib/features/chat/data/chat_repository.dart index bf7e615..2a48e03 100644 --- a/lib/features/chat/data/chat_repository.dart +++ b/lib/features/chat/data/chat_repository.dart @@ -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> 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 sendTextMessage(String roomId, String text) async { + /// Sends a plain text message. Supports replies via [inReplyToEventId]. + Future 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 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 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 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 loadMoreMessages(String roomId) async { final room = _getRoom(roomId); if (room == null) return; await room.requestHistory(); } - List _mapTimeline(Timeline timeline, Room room) { + Future> _mapTimeline(Timeline timeline, Room room) async { final myUserId = _client.userID ?? ''; - return timeline.events - .map((e) => _toModel(e, timeline, myUserId)) - .toList() - .reversed - .toList(); + final models = []; + 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 _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 = >{}; + for (final r in reactionEvents) { + final emoji = + r.content.tryGet>('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, ); } diff --git a/lib/features/chat/presentation/chat_controller.dart b/lib/features/chat/presentation/chat_controller.dart index 04ef6a2..6babb52 100644 --- a/lib/features/chat/presentation/chat_controller.dart +++ b/lib/features/chat/presentation/chat_controller.dart @@ -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> 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 send(String roomId, String text) async { + Future 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 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 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 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(); diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index bb7ab4e..11cf528 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -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 createState() => _ChatScreenState(); +} +class _ChatScreenState extends ConsumerState { + 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( + 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( + 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(); + } + }, ); } } diff --git a/lib/features/chat/presentation/message_input.dart b/lib/features/chat/presentation/message_input.dart index ae931a2..395384f 100644 --- a/lib/features/chat/presentation/message_input.dart +++ b/lib/features/chat/presentation/message_input.dart @@ -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 Function(String text) onSend; + + /// Called with a [MatrixFile] when the user picks an attachment. + final Future 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 createState() => _MessageInputState(); } @@ -20,15 +53,14 @@ class MessageInput extends StatefulWidget { class _MessageInputState extends State { 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 { await widget.onSend(text); } + Future _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 { ), 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 { ); } } + +// --------------------------------------------------------------------------- +// 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/rooms/presentation/rooms_screen.dart b/lib/features/rooms/presentation/rooms_screen.dart index b5a7e27..4b86282 100644 --- a/lib/features/rooms/presentation/rooms_screen.dart +++ b/lib/features/rooms/presentation/rooms_screen.dart @@ -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 { IconButton( icon: const Icon(Icons.edit_square), tooltip: 'New message', - onPressed: () { - // Phase 2: start a new DM or group chat - }, + onPressed: () => showUserSearchDialog(context), ), ], ) diff --git a/lib/features/rooms/presentation/user_search_dialog.dart b/lib/features/rooms/presentation/user_search_dialog.dart new file mode 100644 index 0000000..b9da0af --- /dev/null +++ b/lib/features/rooms/presentation/user_search_dialog.dart @@ -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> 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 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 _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 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), + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/spaces/data/spaces_repository.dart b/lib/features/spaces/data/spaces_repository.dart new file mode 100644 index 0000000..401cc51 --- /dev/null +++ b/lib/features/spaces/data/spaces_repository.dart @@ -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 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 getRoomsInSpace(String spaceId) { + final space = _client.getRoomById(spaceId); + if (space == null || !space.isSpace) return []; + + final result = []; + 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> 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, + ); + } +} diff --git a/lib/features/spaces/domain/space_model.dart b/lib/features/spaces/domain/space_model.dart new file mode 100644 index 0000000..fed0274 --- /dev/null +++ b/lib/features/spaces/domain/space_model.dart @@ -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; +} diff --git a/lib/features/spaces/presentation/spaces_screen.dart b/lib/features/spaces/presentation/spaces_screen.dart index 1636882..e402fb2 100644 --- a/lib/features/spaces/presentation/spaces_screen.dart +++ b/lib/features/spaces/presentation/spaces_screen.dart @@ -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> spacesStream(Ref ref) { + return ref.watch(spacesRepositoryProvider).watchSpaces(); +} + +@riverpod +List 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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2d7be87..5925ac9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.0.0+1 +version: 1.1.0+2 environment: sdk: '>=3.11.0 <4.0.0' @@ -33,6 +33,15 @@ dependencies: livekit_client: ^2.4.0 flutter_webrtc: ^0.12.0 + # HTTP client (used directly in LiveKit JWT fetch) + http: ^1.0.0 + + # Media upload + file_picker: ^8.0.0 + + # Emoji reactions + emoji_picker_flutter: ^4.0.0 + # UI cached_network_image: ^3.4.0 timeago: ^3.7.0