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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user