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">
|
<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
|
<application
|
||||||
android:label="m8chat_app"
|
android:label="m8chat_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -14,7 +24,7 @@
|
|||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
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. -->
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
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.
|
// Riverpod notifier that owns the auth state machine.
|
||||||
// All login/logout/session-restore transitions go through here.
|
// 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/data/auth_repository.dart';
|
||||||
import '../../features/auth/domain/auth_failure.dart';
|
import '../../features/auth/domain/auth_failure.dart';
|
||||||
|
import '../storage/sync_persistence_service.dart';
|
||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
import 'secure_storage.dart';
|
import 'secure_storage.dart';
|
||||||
|
|
||||||
@@ -48,6 +49,9 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
accessToken: credentials.accessToken,
|
accessToken: credentials.accessToken,
|
||||||
deviceId: credentials.deviceId,
|
deviceId: credentials.deviceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resume background persistence for the restored session.
|
||||||
|
ref.read(syncPersistenceServiceProvider).start();
|
||||||
} on AuthFailure {
|
} on AuthFailure {
|
||||||
// Stored credentials are invalid; force re-login.
|
// Stored credentials are invalid; force re-login.
|
||||||
await storage.clearCredentials();
|
await storage.clearCredentials();
|
||||||
@@ -84,6 +88,9 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
accessToken: response.accessToken,
|
accessToken: response.accessToken,
|
||||||
deviceId: response.deviceId,
|
deviceId: response.deviceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Start background sync-to-database persistence now that we are logged in.
|
||||||
|
ref.read(syncPersistenceServiceProvider).start();
|
||||||
} on AuthFailure catch (failure) {
|
} on AuthFailure catch (failure) {
|
||||||
state = AuthState.unauthenticated(failure: failure.userMessage);
|
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
|
// Version: 1.1.0 | Created: 2026-04-01
|
||||||
// Call controller stub. LiveKit integration deferred to Phase 2.
|
// 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 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../data/livekit_service.dart';
|
||||||
import '../domain/call_state.dart';
|
import '../domain/call_state.dart';
|
||||||
|
|
||||||
part 'call_controller.g.dart';
|
part 'call_controller.g.dart';
|
||||||
|
|
||||||
@Riverpod(keepAlive: false)
|
@Riverpod(keepAlive: false)
|
||||||
class CallController extends _$CallController {
|
class CallController extends _$CallController {
|
||||||
|
Timer? _durationTimer;
|
||||||
|
Duration _elapsed = Duration.zero;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CallState build() => const CallState.idle();
|
CallState build() => const CallState.idle();
|
||||||
|
|
||||||
/// Phase 2: join a LiveKit room via MatrixRTC JWT endpoint.
|
/// Join a LiveKit room via MatrixRTC JWT endpoint.
|
||||||
Future<void> joinCall(String roomId) async {
|
///
|
||||||
|
/// 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);
|
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 {
|
Future<void> endCall() async {
|
||||||
|
_durationTimer?.cancel();
|
||||||
|
_durationTimer = null;
|
||||||
|
await ref.read(liveKitServiceProvider).disconnect();
|
||||||
state = const CallState.ended();
|
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
|
// Version: 1.1.0 | Created: 2026-04-01
|
||||||
// Call screen skeleton. Phase 2 will wire in LiveKit video/audio.
|
// 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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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:go_router/go_router.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
|
||||||
|
import '../data/livekit_service.dart';
|
||||||
import '../domain/call_state.dart';
|
import '../domain/call_state.dart';
|
||||||
import 'call_controller.dart';
|
import 'call_controller.dart';
|
||||||
|
|
||||||
class CallScreen extends ConsumerWidget {
|
class CallScreen extends ConsumerStatefulWidget {
|
||||||
const CallScreen({super.key, required this.roomId});
|
const CallScreen({super.key, required this.roomId});
|
||||||
|
|
||||||
final String roomId;
|
final String roomId;
|
||||||
|
|
||||||
@override
|
@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);
|
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(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
// 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),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.videocam_off_outlined,
|
Icons.person_outline,
|
||||||
size: 80,
|
size: 96,
|
||||||
color: Colors.white.withAlpha(153),
|
color: Colors.white.withAlpha(77),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
switch (callState) {
|
'Waiting for video...',
|
||||||
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(
|
style: TextStyle(
|
||||||
color: Colors.white.withAlpha(153),
|
color: Colors.white.withAlpha(153),
|
||||||
fontSize: 14,
|
fontSize: 16,
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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
|
// Version: 1.1.0 | Created: 2026-04-01
|
||||||
// Chat repository. Bridges Matrix SDK timeline to app domain models.
|
// Chat repository — bridges Matrix SDK timeline to app domain models.
|
||||||
// Uses room.getTimeline() — timeline is async in matrix 0.33.0.
|
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
|
||||||
|
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -23,40 +23,67 @@ class ChatRepository {
|
|||||||
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
|
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
|
||||||
|
|
||||||
/// Returns a stream of message lists for [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* {
|
Stream<List<MessageModel>> watchTimeline(String roomId) async* {
|
||||||
final room = _getRoom(roomId);
|
final room = _getRoom(roomId);
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
|
|
||||||
final timeline = await room.getTimeline(
|
final timeline = await room.getTimeline();
|
||||||
onUpdate: () {
|
|
||||||
// Handled by the stream controller below.
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Emit the initial state.
|
yield await _mapTimeline(timeline, room);
|
||||||
yield _mapTimeline(timeline, room);
|
|
||||||
|
|
||||||
// Emit on subsequent sync events that affect this room.
|
|
||||||
await for (final update in _client.onSync.stream) {
|
await for (final update in _client.onSync.stream) {
|
||||||
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
|
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
|
||||||
if (updatesThisRoom) {
|
if (updatesThisRoom) {
|
||||||
yield _mapTimeline(timeline, room);
|
yield await _mapTimeline(timeline, room);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up timeline subscriptions when the stream is cancelled.
|
|
||||||
timeline.cancelSubscriptions();
|
timeline.cancelSubscriptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a plain text message to [roomId].
|
/// Sends a plain text message. Supports replies via [inReplyToEventId].
|
||||||
Future<void> sendTextMessage(String roomId, String text) async {
|
Future<void> sendTextMessage(
|
||||||
|
String roomId,
|
||||||
|
String text, {
|
||||||
|
String? inReplyToEventId,
|
||||||
|
}) async {
|
||||||
final room = _getRoom(roomId);
|
final room = _getRoom(roomId);
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
|
|
||||||
|
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);
|
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].
|
/// Sends a read receipt for the latest event in [roomId].
|
||||||
Future<void> markAsRead(String roomId) async {
|
Future<void> markAsRead(String roomId) async {
|
||||||
@@ -67,27 +94,63 @@ class ChatRepository {
|
|||||||
await room.setReadMarker(lastEventId, mRead: lastEventId);
|
await room.setReadMarker(lastEventId, mRead: lastEventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests older messages be loaded (pagination).
|
/// Requests older messages (pagination).
|
||||||
Future<void> loadMoreMessages(String roomId) async {
|
Future<void> loadMoreMessages(String roomId) async {
|
||||||
final room = _getRoom(roomId);
|
final room = _getRoom(roomId);
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
await room.requestHistory();
|
await room.requestHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MessageModel> _mapTimeline(Timeline timeline, Room room) {
|
Future<List<MessageModel>> _mapTimeline(Timeline timeline, Room room) async {
|
||||||
final myUserId = _client.userID ?? '';
|
final myUserId = _client.userID ?? '';
|
||||||
return timeline.events
|
final models = <MessageModel>[];
|
||||||
.map((e) => _toModel(e, timeline, myUserId))
|
for (final e in timeline.events) {
|
||||||
.toList()
|
models.add(await _toModel(e, timeline, myUserId));
|
||||||
.reversed
|
}
|
||||||
.toList();
|
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(
|
final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback(
|
||||||
event.senderId,
|
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(
|
return MessageModel(
|
||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
roomId: event.roomId ?? '',
|
roomId: event.roomId ?? '',
|
||||||
@@ -98,10 +161,13 @@ class ChatRepository {
|
|||||||
timestamp: event.originServerTs,
|
timestamp: event.originServerTs,
|
||||||
type: _messageType(event),
|
type: _messageType(event),
|
||||||
body: event.redacted ? null : event.body,
|
body: event.redacted ? null : event.body,
|
||||||
mxcUrl: _extractMxcUrl(event),
|
mediaUrl: resolvedMediaUrl,
|
||||||
|
mxcUrl: mxcUrl,
|
||||||
inReplyToEventId: event.relationshipEventId,
|
inReplyToEventId: event.relationshipEventId,
|
||||||
isMine: event.senderId == myUserId,
|
isMine: event.senderId == myUserId,
|
||||||
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
|
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
|
||||||
|
reactions: reactions,
|
||||||
|
readByUserIds: readBy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Version: 1.0.0 | Created: 2026-04-01
|
// Version: 1.1.0 | Created: 2026-04-01
|
||||||
// Riverpod providers for chat timeline.
|
// Riverpod providers for chat timeline, send, upload, react, reply.
|
||||||
|
|
||||||
|
import 'package:matrix/matrix.dart' show MatrixFile;
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import '../data/chat_repository.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.
|
/// Sends a text message. Returns an error string on failure, null on success.
|
||||||
|
/// Also handles sending replies when [inReplyToEventId] is set.
|
||||||
@riverpod
|
@riverpod
|
||||||
class SendMessage extends _$SendMessage {
|
class SendMessage extends _$SendMessage {
|
||||||
@override
|
@override
|
||||||
bool build() => false; // isSending
|
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;
|
if (text.trim().isEmpty) return null;
|
||||||
state = true;
|
state = true;
|
||||||
try {
|
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;
|
return null;
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
return e.toString();
|
return e.toString();
|
||||||
|
|||||||
@@ -1,26 +1,56 @@
|
|||||||
// Version: 1.0.0 | Created: 2026-04-01
|
// Version: 1.1.0 | Created: 2026-04-01
|
||||||
// Full chat screen — timeline + message input.
|
// 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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../../core/network/matrix_client.dart';
|
||||||
|
import '../domain/message_model.dart';
|
||||||
import 'chat_controller.dart';
|
import 'chat_controller.dart';
|
||||||
import 'message_bubble.dart';
|
import 'message_bubble.dart';
|
||||||
import 'message_input.dart';
|
import 'message_input.dart';
|
||||||
|
|
||||||
class ChatScreen extends ConsumerWidget {
|
class ChatScreen extends ConsumerStatefulWidget {
|
||||||
const ChatScreen({super.key, required this.roomId});
|
const ChatScreen({super.key, required this.roomId});
|
||||||
|
|
||||||
final String roomId;
|
final String roomId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||||
// Decode the roomId — GoRouter encodes ! as %21 etc.
|
}
|
||||||
final decodedRoomId = Uri.decodeComponent(roomId);
|
|
||||||
|
|
||||||
|
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 client = ref.watch(matrixClientProvider);
|
||||||
final room = client.getRoomById(decodedRoomId);
|
final room = client.getRoomById(_decodedRoomId);
|
||||||
final roomName = room?.getLocalizedDisplayname() ?? 'Chat';
|
final roomName = room?.getLocalizedDisplayname() ?? 'Chat';
|
||||||
final roomAvatar = room?.avatar?.toString();
|
final roomAvatar = room?.avatar?.toString();
|
||||||
|
|
||||||
@@ -35,30 +65,50 @@ class ChatScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
// Voice call
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.call),
|
icon: const Icon(Icons.call),
|
||||||
tooltip: 'Start call (Phase 2)',
|
tooltip: 'Voice call',
|
||||||
onPressed: null, // Phase 2
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
tooltip: 'Room options',
|
tooltip: 'Room options',
|
||||||
onPressed: () {
|
onPressed: () {},
|
||||||
// Phase 2: room settings sheet
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _Timeline(roomId: decodedRoomId)),
|
Expanded(
|
||||||
_Input(roomId: decodedRoomId),
|
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 {
|
class _RoomAvatarSmall extends StatelessWidget {
|
||||||
const _RoomAvatarSmall({required this.name, this.avatarUrl});
|
const _RoomAvatarSmall({required this.name, this.avatarUrl});
|
||||||
|
|
||||||
@@ -89,10 +139,15 @@ class _RoomAvatarSmall extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _Timeline extends ConsumerWidget {
|
class _Timeline extends ConsumerWidget {
|
||||||
const _Timeline({required this.roomId});
|
const _Timeline({required this.roomId, required this.onReply});
|
||||||
|
|
||||||
final String roomId;
|
final String roomId;
|
||||||
|
final void Function(MessageModel) onReply;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -123,7 +178,12 @@ class _Timeline extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
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;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isSending = ref.watch(sendMessageProvider);
|
final isSending = ref.watch(sendMessageProvider);
|
||||||
|
final uploadState = ref.watch(uploadFileProvider);
|
||||||
|
final isUploading = uploadState == '';
|
||||||
|
|
||||||
return MessageInput(
|
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 {
|
onSend: (text) async {
|
||||||
final error = await ref
|
final error = await ref
|
||||||
.read(sendMessageProvider.notifier)
|
.read(sendMessageProvider.notifier)
|
||||||
.send(roomId, text);
|
.send(roomId, text, inReplyToEventId: replyToEventId);
|
||||||
|
onCancelReply?.call();
|
||||||
if (error != null && context.mounted) {
|
if (error != null && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to send message: $error'),
|
content: Text('Failed to send: $error'),
|
||||||
backgroundColor: Theme.of(context).colorScheme.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
|
// Version: 1.1.0 | Created: 2026-04-01
|
||||||
// Message input bar. Text field + send button.
|
// 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: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 {
|
class MessageInput extends StatefulWidget {
|
||||||
const MessageInput({
|
const MessageInput({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
required this.isSending,
|
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;
|
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;
|
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
|
@override
|
||||||
State<MessageInput> createState() => _MessageInputState();
|
State<MessageInput> createState() => _MessageInputState();
|
||||||
}
|
}
|
||||||
@@ -20,15 +53,14 @@ class MessageInput extends StatefulWidget {
|
|||||||
class _MessageInputState extends State<MessageInput> {
|
class _MessageInputState extends State<MessageInput> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
bool _hasText = false;
|
bool _hasText = false;
|
||||||
|
bool _isPickingFile = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller.addListener(() {
|
_controller.addListener(() {
|
||||||
final hasText = _controller.text.trim().isNotEmpty;
|
final hasText = _controller.text.trim().isNotEmpty;
|
||||||
if (hasText != _hasText) {
|
if (hasText != _hasText) setState(() => _hasText = hasText);
|
||||||
setState(() => _hasText = hasText);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +77,62 @@ class _MessageInputState extends State<MessageInput> {
|
|||||||
await widget.onSend(text);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
border: Border(
|
border: Border(
|
||||||
@@ -59,14 +141,42 @@ class _MessageInputState extends State<MessageInput> {
|
|||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Row(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
// Reply quote strip
|
||||||
icon: const Icon(Icons.add),
|
if (widget.replyTo != null)
|
||||||
tooltip: 'Attach file (Phase 2)',
|
_ReplyQuote(
|
||||||
onPressed: null, // Phase 2
|
replyTo: widget.replyTo!,
|
||||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
onCancel: widget.onCancelReply ?? () {},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 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(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -84,6 +194,8 @@ class _MessageInputState extends State<MessageInput> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Send button
|
||||||
AnimatedSwitcher(
|
AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
child: widget.isSending
|
child: widget.isSending
|
||||||
@@ -94,7 +206,9 @@ class _MessageInputState extends State<MessageInput> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -110,6 +224,69 @@ 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.
|
// Main rooms list screen with bottom navigation.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -9,6 +9,7 @@ import '../../profile/presentation/profile_screen.dart';
|
|||||||
import '../../spaces/presentation/spaces_screen.dart';
|
import '../../spaces/presentation/spaces_screen.dart';
|
||||||
import 'room_tile.dart';
|
import 'room_tile.dart';
|
||||||
import 'rooms_controller.dart';
|
import 'rooms_controller.dart';
|
||||||
|
import 'user_search_dialog.dart';
|
||||||
|
|
||||||
class RoomsScreen extends ConsumerStatefulWidget {
|
class RoomsScreen extends ConsumerStatefulWidget {
|
||||||
const RoomsScreen({super.key});
|
const RoomsScreen({super.key});
|
||||||
@@ -64,9 +65,7 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit_square),
|
icon: const Icon(Icons.edit_square),
|
||||||
tooltip: 'New message',
|
tooltip: 'New message',
|
||||||
onPressed: () {
|
onPressed: () => showUserSearchDialog(context),
|
||||||
// Phase 2: start a new DM or group chat
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
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
|
// Version: 1.1.0 | Created: 2026-04-01
|
||||||
// Spaces screen stub — Phase 2 will implement full spaces navigation.
|
// Spaces screen — list of Matrix spaces with expandable child room lists.
|
||||||
|
|
||||||
import 'package:flutter/material.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';
|
||||||
|
|
||||||
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});
|
const SpacesScreen({super.key, this.embedded = false});
|
||||||
|
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final body = Center(
|
final spacesAsync = ref.watch(spacesStreamProvider);
|
||||||
|
|
||||||
|
final body = spacesAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, _) => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Text('Could not load spaces: $err'),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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;
|
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
|
name: m8chat_app
|
||||||
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.1.0+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.11.0 <4.0.0'
|
sdk: '>=3.11.0 <4.0.0'
|
||||||
@@ -33,6 +33,15 @@ dependencies:
|
|||||||
livekit_client: ^2.4.0
|
livekit_client: ^2.4.0
|
||||||
flutter_webrtc: ^0.12.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
|
# UI
|
||||||
cached_network_image: ^3.4.0
|
cached_network_image: ^3.4.0
|
||||||
timeago: ^3.7.0
|
timeago: ^3.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user