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,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Network access — MUST be in main manifest; release builds ignore debug manifest -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Voice and video calls -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<application
android:label="m8chat_app"
android:name="${applicationName}"
@@ -14,7 +24,7 @@
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
while the Flutter UI initialises. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"

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),
),
),
],
),
),
);
}
}

View File

@@ -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