Files
m8chat-app2/lib/core/storage/database.dart
help4bis b941cdfe4b refactor: /simplify — 22 fixes from 3-agent code review
Critical:
- Fix MXC URI resolution: all avatars/images now resolve mxc:// to HTTP
- Sync persistence: only write changed rooms, batch message upserts
- lastActivityAt uses room.lastEvent.originServerTs, not creation time

High:
- Shared MatrixAvatar widget replaces 6 duplicate implementations
- CallScreen decodes roomId before LiveKit JWT fetch
- Decline button actually dismisses incoming call overlay
- EventTypes constants replace raw string literals
- LiveKitService uses lazy auth reads, onDispose disconnects

Medium:
- CallController is keepAlive with timer/room cleanup
- authRepository is keepAlive (used from keepAlive notifier)
- StreamController not closed in stopListening (crash fix)
- Index on messages.roomId for query performance
- 400ms debounce on user search
- Static DateFormat in MessageBubble
- Hardcoded strings replaced with AppConfig refs
- Duplicate isDirectMessage field removed from RoomModel
- E2EE profile claim corrected to Phase 3

Shared utilities:
- lib/shared/widgets/matrix_avatar.dart
- lib/shared/utils/mxc_url.dart
- lib/shared/utils/room_preview.dart
- lib/shared/utils/matrix_id.dart

rawJson column removed (unused, caused main-thread jsonEncode)
Schema migrated to v2 with roomId index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:19:22 +10:00

186 lines
6.2 KiB
Dart

// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
// 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.
@TableIndex(name: 'idx_messages_room_id', columns: {#roomId})
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
@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 => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (migrator, from, to) async {
if (from < 2) {
// v2: drop rawJson column, add index on roomId.
// Drift cannot drop columns — recreate the table.
await customStatement(
'CREATE TABLE IF NOT EXISTS messages_new ('
' id TEXT NOT NULL PRIMARY KEY, '
' room_id TEXT NOT NULL, '
' sender_id TEXT NOT NULL, '
' body TEXT, '
' type TEXT NOT NULL, '
' timestamp INTEGER NOT NULL'
')',
);
await customStatement(
'INSERT OR IGNORE INTO messages_new '
'(id, room_id, sender_id, body, type, timestamp) '
'SELECT id, room_id, sender_id, body, type, timestamp FROM messages',
);
await customStatement('DROP TABLE IF EXISTS messages');
await customStatement('ALTER TABLE messages_new RENAME TO messages');
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_messages_room_id '
'ON messages (room_id)',
);
}
},
onCreate: (migrator) async {
await migrator.createAll();
},
);
}
// ---------------------------------------------------------------------------
// 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);
/// Insert or replace multiple message rows in a single batch transaction.
Future<void> upsertMessages(List<MessagesTableCompanion> messages) async {
await batch((b) {
for (final msg in messages) {
b.insert(messagesTable, msg, onConflict: DoUpdate((_) => msg));
}
});
}
/// 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;
}