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:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
142
lib/core/storage/database.dart
Normal file
142
lib/core/storage/database.dart
Normal 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;
|
||||
}
|
||||
112
lib/core/storage/sync_persistence_service.dart
Normal file
112
lib/core/storage/sync_persistence_service.dart
Normal 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;
|
||||
}
|
||||
164
lib/features/calls/data/livekit_service.dart
Normal file
164
lib/features/calls/data/livekit_service.dart
Normal 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);
|
||||
}
|
||||
119
lib/features/calls/data/matrixrtc_repository.dart
Normal file
119
lib/features/calls/data/matrixrtc_repository.dart
Normal 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;
|
||||
}
|
||||
18
lib/features/calls/domain/incoming_call.dart
Normal file
18
lib/features/calls/domain/incoming_call.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
202
lib/features/calls/presentation/incoming_call_overlay.dart
Normal file
202
lib/features/calls/presentation/incoming_call_overlay.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
222
lib/features/rooms/presentation/user_search_dialog.dart
Normal file
222
lib/features/rooms/presentation/user_search_dialog.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/features/spaces/data/spaces_repository.dart
Normal file
71
lib/features/spaces/data/spaces_repository.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/features/spaces/domain/space_model.dart
Normal file
28
lib/features/spaces/domain/space_model.dart
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
11
pubspec.yaml
11
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
|
||||
|
||||
Reference in New Issue
Block a user