diff --git a/lib/core/storage/database.dart b/lib/core/storage/database.dart index 456cfd3..42a3c9a 100644 --- a/lib/core/storage/database.dart +++ b/lib/core/storage/database.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// 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 @@ -33,6 +33,7 @@ class RoomsTable extends Table { } /// 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'; @@ -43,7 +44,6 @@ class MessagesTable extends Table { 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> get primaryKey => {id}; @@ -61,7 +61,41 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 1; + 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(); + }, + ); } // --------------------------------------------------------------------------- @@ -99,6 +133,15 @@ extension MessagesDao on AppDatabase { Future upsertMessage(MessagesTableCompanion message) => into(messagesTable).insertOnConflictUpdate(message); + /// Insert or replace multiple message rows in a single batch transaction. + Future upsertMessages(List 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> watchByRoom(String roomId) { return (select(messagesTable) diff --git a/lib/core/storage/sync_persistence_service.dart b/lib/core/storage/sync_persistence_service.dart index d00179e..717ff85 100644 --- a/lib/core/storage/sync_persistence_service.dart +++ b/lib/core/storage/sync_persistence_service.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // SyncPersistenceService — listens to the Matrix sync stream and writes room // and message data into the Drift database. // @@ -7,12 +7,13 @@ // 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 '../../shared/utils/mxc_url.dart'; +import '../../shared/utils/room_preview.dart'; import '../network/matrix_client.dart'; import 'database.dart'; @@ -40,41 +41,57 @@ class SyncPersistenceService { } Future _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), - ), - ); + // Collect room IDs that changed in this sync batch. + final changedIds = { + ...?update.rooms?.join?.keys, + ...?update.rooms?.invite?.keys, + ...?update.rooms?.leave?.keys, + }; + + // Only upsert rooms that actually changed — avoids O(n) writes. + if (changedIds.isNotEmpty) { + final roomCompanions = []; + for (final roomId in changedIds) { + final room = _client.getRoomById(roomId); + if (room == null) continue; + roomCompanions.add( + RoomsTableCompanion( + id: Value(room.id), + name: Value(room.getLocalizedDisplayname()), + avatarUrl: Value(resolveMxcUrl(_client, room.avatar)), + lastMessage: Value(lastMessagePreview(room)), + lastActivityAt: Value( + (room.lastEvent?.originServerTs ?? room.timeCreated) + .millisecondsSinceEpoch, + ), + unreadCount: Value(room.notificationCount), + isDm: Value(room.isDirectChat), + ), + ); + } + for (final companion in roomCompanions) { + await _db.upsertRoom(companion); + } } // Persist new events from joined rooms in this sync batch. final joinedRooms = update.rooms?.join; if (joinedRooms == null) return; + final messageCompanions = []; + for (final entry in joinedRooms.entries) { final roomId = entry.key; final timeline = entry.value.timeline; if (timeline == null) continue; - // timeline.events is List? — 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; + if (event.type != EventTypes.Message) continue; - // eventId, senderId, originServerTs are all non-null on MatrixEvent. - await _db.upsertMessage( + messageCompanions.add( MessagesTableCompanion( id: Value(event.eventId), roomId: Value(roomId), @@ -82,22 +99,15 @@ class SyncPersistenceService { 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, - }; + // Batch insert all messages in a single transaction. + if (messageCompanions.isNotEmpty) { + await _db.upsertMessages(messageCompanions); + } } } diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 14ea193..dfd175f 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -1,4 +1,4 @@ -// Version: 1.0.3 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Auth repository: handles all Matrix login/logout API interactions. // Uses the Matrix Dart SDK — no raw HTTP calls for auth. @@ -11,7 +11,7 @@ import '../domain/auth_failure.dart'; part 'auth_repository.g.dart'; -@riverpod +@Riverpod(keepAlive: true) AuthRepository authRepository(Ref ref) { return AuthRepository(client: ref.watch(matrixClientProvider)); } @@ -41,7 +41,7 @@ class AuthRepository { LoginType.mLoginPassword, identifier: AuthenticationUserIdentifier(user: username), password: password, - initialDeviceDisplayName: 'M8Chat', + initialDeviceDisplayName: AppConfig.appName, ); } on MatrixException catch (e) { throw switch (e.errcode) { @@ -87,7 +87,7 @@ class AuthRepository { newToken: accessToken, newUserID: userId, newDeviceID: deviceId, - newDeviceName: 'M8Chat', + newDeviceName: AppConfig.appName, newHomeserver: Uri.parse(AppConfig.matrixBaseUrl), newOlmAccount: null, ); diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 6d7378d..ade32fa 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -1,10 +1,11 @@ -// Version: 1.0.0 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Login screen. Username + password only. No registration link. // Respects system theme preference. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/config/app_config.dart'; import 'login_controller.dart'; class LoginScreen extends ConsumerStatefulWidget { @@ -231,7 +232,7 @@ class _ServerLabel extends StatelessWidget { @override Widget build(BuildContext context) { return Text( - 'matrix.m8chat.au', + AppConfig.matrixServerName, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withAlpha(102), diff --git a/lib/features/calls/data/livekit_service.dart b/lib/features/calls/data/livekit_service.dart index a62758d..ed8f43d 100644 --- a/lib/features/calls/data/livekit_service.dart +++ b/lib/features/calls/data/livekit_service.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt // endpoint, then connects a LiveKit Room using that token. // @@ -54,9 +54,9 @@ final class LiveKitFailed extends LiveKitResult { /// Manages LiveKit room connections for MatrixRTC. class LiveKitService { - LiveKitService({required AuthState authState}) : _authState = authState; + LiveKitService({required Ref ref}) : _ref = ref; - final AuthState _authState; + final Ref _ref; Room? _activeRoom; Room? get activeRoom => _activeRoom; @@ -67,7 +67,7 @@ class LiveKitService { /// 1. GET `/_matrix/livekit/jwt?roomId={id}&userId={id}` /// 2. Use returned token + LiveKit WS URL to connect a [Room] Future connect(String matrixRoomId) async { - final auth = _authState; + final auth = _ref.read(authProvider); if (auth is! AuthAuthenticated) { return const LiveKitFailed(LiveKitNotAuthenticated()); } @@ -94,6 +94,7 @@ class LiveKitService { _activeRoom = room; return LiveKitConnected(room: room); } on Exception catch (e) { + await room.disconnect(); await room.dispose(); return LiveKitFailed(LiveKitConnectFailed(e.toString())); } @@ -101,9 +102,12 @@ class LiveKitService { /// Disconnect and dispose the active room. Future disconnect() async { - await _activeRoom?.disconnect(); - await _activeRoom?.dispose(); + final room = _activeRoom; _activeRoom = null; + if (room != null) { + await room.disconnect(); + await room.dispose(); + } } Future<_JwtFetchResult> _fetchJwt({ @@ -159,6 +163,7 @@ final class _JwtError extends _JwtFetchResult { @Riverpod(keepAlive: true) LiveKitService liveKitService(Ref ref) { - final authState = ref.watch(authProvider); - return LiveKitService(authState: authState); + final service = LiveKitService(ref: ref); + ref.onDispose(() async => service.disconnect()); + return service; } diff --git a/lib/features/calls/data/matrixrtc_repository.dart b/lib/features/calls/data/matrixrtc_repository.dart index fae956a..e485a08 100644 --- a/lib/features/calls/data/matrixrtc_repository.dart +++ b/lib/features/calls/data/matrixrtc_repository.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // MatrixRTC repository — handles outgoing call invites and detects incoming // calls via m.call.invite events per MSC4143 (MatrixRTC spec). // @@ -13,6 +13,8 @@ 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 '../../../shared/utils/matrix_id.dart'; +import '../../../shared/utils/mxc_url.dart'; import '../domain/incoming_call.dart'; part 'matrixrtc_repository.g.dart'; @@ -42,6 +44,11 @@ class MatrixRtcRepository { void stopListening() { _eventSubscription?.cancel(); _eventSubscription = null; + } + + /// Close the stream controller. Called from ref.onDispose only. + void dispose() { + stopListening(); _incomingCallController.close(); } @@ -59,7 +66,7 @@ class MatrixRtcRepository { final callId = 'call_${DateTime.now().millisecondsSinceEpoch}'; await room.sendEvent({ - 'msgtype': 'm.call.invite', + 'msgtype': EventTypes.CallInvite, 'call_id': callId, 'lifetime': 60000, // 60 seconds before invite expires 'offer': { @@ -69,12 +76,12 @@ class MatrixRtcRepository { 'version': '1', 'invitee': null, // null = invite entire room 'm.intentional_mentions': {'user_ids': [], 'room': false}, - }, type: 'm.call.invite'); + }, type: EventTypes.CallInvite); } void _onEvent(EventUpdate update) { if (update.type != EventUpdateType.timeline) return; - if (update.content['type'] != 'm.call.invite') return; + if (update.content['type'] != EventTypes.CallInvite) return; final senderId = update.content['sender'] as String?; // Ignore our own invites. @@ -98,8 +105,8 @@ class MatrixRtcRepository { roomId: roomId, callerId: senderId ?? '', callerDisplayName: - senderProfile?.displayName ?? senderId?.split(':').first ?? '', - callerAvatarUrl: senderProfile?.avatarUrl?.toString(), + senderProfile?.displayName ?? senderId?.matrixLocalpart ?? '', + callerAvatarUrl: resolveMxcUrl(_client, senderProfile?.avatarUrl), isVideo: (content['offer'] != null), ), ); @@ -114,6 +121,6 @@ MatrixRtcRepository matrixRtcRepository(Ref ref) { final repo = MatrixRtcRepository(client: client, myUserId: myUserId); repo.startListening(); - ref.onDispose(repo.stopListening); + ref.onDispose(repo.dispose); return repo; } diff --git a/lib/features/calls/presentation/call_controller.dart b/lib/features/calls/presentation/call_controller.dart index 58a6cc7..b987525 100644 --- a/lib/features/calls/presentation/call_controller.dart +++ b/lib/features/calls/presentation/call_controller.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Call controller — manages LiveKit room connection lifecycle. // Transitions through idle → connecting → active → ended states. @@ -12,13 +12,20 @@ import '../domain/call_state.dart'; part 'call_controller.g.dart'; -@Riverpod(keepAlive: false) +@Riverpod(keepAlive: true) class CallController extends _$CallController { Timer? _durationTimer; Duration _elapsed = Duration.zero; @override - CallState build() => const CallState.idle(); + CallState build() { + ref.onDispose(() { + _durationTimer?.cancel(); + _durationTimer = null; + ref.read(liveKitServiceProvider).disconnect(); + }); + return const CallState.idle(); + } /// Join a LiveKit room via MatrixRTC JWT endpoint. /// diff --git a/lib/features/calls/presentation/call_screen.dart b/lib/features/calls/presentation/call_screen.dart index 580566e..6e74de2 100644 --- a/lib/features/calls/presentation/call_screen.dart +++ b/lib/features/calls/presentation/call_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Full call screen with LiveKit video/audio. // - Remote video: full screen background // - Local video: picture-in-picture overlay (bottom right) @@ -33,7 +33,7 @@ class _CallScreenState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { ref .read(callControllerProvider.notifier) - .joinCall(widget.roomId, withVideo: true); + .joinCall(Uri.decodeComponent(widget.roomId), withVideo: true); }); } diff --git a/lib/features/calls/presentation/incoming_call_overlay.dart b/lib/features/calls/presentation/incoming_call_overlay.dart index 61f0f4e..0281279 100644 --- a/lib/features/calls/presentation/incoming_call_overlay.dart +++ b/lib/features/calls/presentation/incoming_call_overlay.dart @@ -1,13 +1,13 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // 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 '../../../shared/widgets/matrix_avatar.dart'; import '../data/matrixrtc_repository.dart'; import '../domain/incoming_call.dart'; @@ -58,14 +58,24 @@ class IncomingCallOverlayHost extends ConsumerWidget { } } -class _IncomingCallOverlay extends ConsumerWidget { +class _IncomingCallOverlay extends StatefulWidget { const _IncomingCallOverlay({required this.call}); final IncomingCall call; @override - Widget build(BuildContext context, WidgetRef ref) { + State<_IncomingCallOverlay> createState() => _IncomingCallOverlayState(); +} + +class _IncomingCallOverlayState extends State<_IncomingCallOverlay> { + bool _dismissed = false; + + @override + Widget build(BuildContext context) { + if (_dismissed) return const SizedBox.shrink(); + final theme = Theme.of(context); + final call = widget.call; return Positioned.fill( child: Material( @@ -75,7 +85,13 @@ class _IncomingCallOverlay extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ // Caller avatar - _CallerAvatar(call: call), + MatrixAvatar( + name: call.callerDisplayName.isNotEmpty + ? call.callerDisplayName + : call.callerId, + avatarUrl: call.callerAvatarUrl, + radius: 56, + ), const SizedBox(height: 24), // Caller name @@ -106,10 +122,7 @@ class _IncomingCallOverlay extends ConsumerWidget { 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. - }, + onTap: () => setState(() => _dismissed = true), ), _CallActionButton( icon: call.isVideo ? Icons.videocam : Icons.call, @@ -131,39 +144,6 @@ class _IncomingCallOverlay extends ConsumerWidget { } } -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, diff --git a/lib/features/chat/data/chat_repository.dart b/lib/features/chat/data/chat_repository.dart index 2a48e03..a02ae85 100644 --- a/lib/features/chat/data/chat_repository.dart +++ b/lib/features/chat/data/chat_repository.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Chat repository — bridges Matrix SDK timeline to app domain models. // Phase 2 additions: sendFile, sendReaction, redactEvent, reply support. @@ -6,6 +6,8 @@ import 'package:matrix/matrix.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; +import '../../../shared/utils/matrix_id.dart'; +import '../../../shared/utils/mxc_url.dart'; import '../domain/message_model.dart'; part 'chat_repository.g.dart'; @@ -105,31 +107,21 @@ class ChatRepository { final myUserId = _client.userID ?? ''; final models = []; for (final e in timeline.events) { - models.add(await _toModel(e, timeline, myUserId)); + models.add(_toModel(e, timeline, myUserId)); } return models.reversed.toList(); } - Future _toModel( - Event event, - Timeline timeline, - String myUserId, - ) async { + MessageModel _toModel(Event event, Timeline timeline, String myUserId) { final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback( event.senderId, ); - // Resolve mxc:// to an authenticated HTTP URL for display. + // Resolve mxc:// to an 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. - } + resolvedMediaUrl = resolveMxcUrl(_client, Uri.parse(mxcUrl)); } // Build reactions map: emoji → [senderId, ...] @@ -156,8 +148,8 @@ class ChatRepository { roomId: event.roomId ?? '', senderId: event.senderId, senderDisplayName: - senderProfile.displayName ?? event.senderId.split(':').first, - senderAvatarUrl: senderProfile.avatarUrl?.toString(), + senderProfile.displayName ?? event.senderId.matrixLocalpart, + senderAvatarUrl: resolveMxcUrl(_client, senderProfile.avatarUrl), timestamp: event.originServerTs, type: _messageType(event), body: event.redacted ? null : event.body, diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 11cf528..ae4a61a 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Full chat screen — timeline + input + typing indicators + read receipts // + long-press context menu (reply, react, copy, delete). @@ -10,6 +10,9 @@ import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' show MatrixFile; import '../../../core/network/matrix_client.dart'; +import '../../../shared/utils/matrix_id.dart'; +import '../../../shared/utils/mxc_url.dart'; +import '../../../shared/widgets/matrix_avatar.dart'; import '../domain/message_model.dart'; import 'chat_controller.dart'; import 'message_bubble.dart'; @@ -52,14 +55,14 @@ class _ChatScreenState extends ConsumerState { final client = ref.watch(matrixClientProvider); final room = client.getRoomById(_decodedRoomId); final roomName = room?.getLocalizedDisplayname() ?? 'Chat'; - final roomAvatar = room?.avatar?.toString(); + final roomAvatar = resolveMxcUrl(client, room?.avatar); return Scaffold( appBar: AppBar( titleSpacing: 0, title: Row( children: [ - _RoomAvatarSmall(name: roomName, avatarUrl: roomAvatar), + MatrixAvatar(name: roomName, avatarUrl: roomAvatar, radius: 18), const SizedBox(width: 10), Flexible(child: Text(roomName, overflow: TextOverflow.ellipsis)), ], @@ -105,40 +108,6 @@ class _ChatScreenState extends ConsumerState { } } -// --------------------------------------------------------------------------- -// Room avatar -// --------------------------------------------------------------------------- - -class _RoomAvatarSmall extends StatelessWidget { - const _RoomAvatarSmall({required this.name, this.avatarUrl}); - - final String name; - final String? avatarUrl; - - @override - Widget build(BuildContext context) { - final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; - if (avatarUrl != null) { - return CircleAvatar( - radius: 18, - backgroundImage: NetworkImage(avatarUrl!), - ); - } - return CircleAvatar( - radius: 18, - backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(51), - child: Text( - initials, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } -} - // --------------------------------------------------------------------------- // Timeline // --------------------------------------------------------------------------- @@ -337,7 +306,7 @@ class _TypingIndicator extends ConsumerWidget { // 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) + .map((u) => u.displayName ?? u.id.matrixLocalpart) .toList(); if (typing.isEmpty) return const SizedBox(height: 4); diff --git a/lib/features/chat/presentation/message_bubble.dart b/lib/features/chat/presentation/message_bubble.dart index e371dd8..e8a9fa1 100644 --- a/lib/features/chat/presentation/message_bubble.dart +++ b/lib/features/chat/presentation/message_bubble.dart @@ -1,10 +1,11 @@ -// Version: 1.0.0 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Message bubble widget. Handles text, images, files, redacted, replies. import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import '../../../shared/widgets/matrix_avatar.dart'; import '../domain/message_model.dart'; class MessageBubble extends StatelessWidget { @@ -31,7 +32,11 @@ class MessageBubble extends StatelessWidget { : MainAxisAlignment.start, children: [ if (!isMine) ...[ - _SenderAvatar(message: message), + MatrixAvatar( + name: message.senderDisplayName, + avatarUrl: message.senderAvatarUrl, + radius: 16, + ), const SizedBox(width: 8), ], Flexible( @@ -63,39 +68,6 @@ class MessageBubble extends StatelessWidget { } } -class _SenderAvatar extends StatelessWidget { - const _SenderAvatar({required this.message}); - - final MessageModel message; - - @override - Widget build(BuildContext context) { - final initials = message.senderDisplayName.isNotEmpty - ? message.senderDisplayName[0].toUpperCase() - : '?'; - - if (message.senderAvatarUrl != null) { - return CircleAvatar( - radius: 16, - backgroundImage: CachedNetworkImageProvider(message.senderAvatarUrl!), - ); - } - - return CircleAvatar( - radius: 16, - backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(51), - child: Text( - initials, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ); - } -} - class _BubbleContent extends StatelessWidget { const _BubbleContent({required this.message, required this.isMine}); @@ -238,13 +210,15 @@ class _Timestamp extends StatelessWidget { required this.textColour, }); + static final DateFormat _timeFormat = DateFormat('HH:mm'); + final DateTime timestamp; final bool isEdited; final Color textColour; @override Widget build(BuildContext context) { - final formatted = DateFormat('HH:mm').format(timestamp.toLocal()); + final formatted = _timeFormat.format(timestamp.toLocal()); final label = isEdited ? '$formatted (edited)' : formatted; return Text(label, style: TextStyle(fontSize: 10, color: textColour)); diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 4847421..201f720 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.0.1 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Profile screen. Shows current user info and logout button. import 'package:flutter/material.dart'; @@ -6,7 +6,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_state.dart'; +import '../../../core/config/app_config.dart'; import '../../../core/network/matrix_client.dart'; +import '../../../shared/utils/matrix_id.dart'; +import '../../../shared/widgets/matrix_avatar.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key, this.embedded = false}); @@ -24,14 +27,12 @@ class ProfileScreen extends ConsumerWidget { orElse: () => '', ); - final displayName = client.userID != null - ? (client.userID!.split(':').first.replaceFirst('@', '')) - : 'Unknown'; + final displayName = client.userID?.matrixLocalpart ?? 'Unknown'; final body = ListView( padding: const EdgeInsets.all(24), children: [ - _ProfileAvatar(displayName: displayName), + Center(child: MatrixAvatar(name: displayName, radius: 48)), const SizedBox(height: 16), Center( child: Text( @@ -84,7 +85,7 @@ class ProfileScreen extends ConsumerWidget { ListTile( leading: const Icon(Icons.lock_outline), title: const Text('End-to-end encryption'), - subtitle: const Text('Active — messages are encrypted'), + subtitle: const Text('Setup coming in Phase 3'), enabled: false, ), ListTile( @@ -112,7 +113,7 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), Center( child: Text( - 'M8Chat 1.0.0 · matrix.m8chat.au', + '${AppConfig.appName} ${AppConfig.appVersion} · ${AppConfig.matrixServerName}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withAlpha(77), ), @@ -130,34 +131,6 @@ class ProfileScreen extends ConsumerWidget { } } -class _ProfileAvatar extends StatelessWidget { - const _ProfileAvatar({required this.displayName}); - - final String displayName; - - @override - Widget build(BuildContext context) { - final initials = displayName.isNotEmpty - ? displayName[0].toUpperCase() - : '?'; - - return Center( - child: CircleAvatar( - radius: 48, - backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(51), - child: Text( - initials, - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } -} - class _LogoutButton extends StatelessWidget { const _LogoutButton({required this.onLogout}); @@ -171,7 +144,9 @@ class _LogoutButton extends StatelessWidget { context: context, builder: (_) => AlertDialog( title: const Text('Sign out'), - content: const Text('Are you sure you want to sign out of M8Chat?'), + content: Text( + 'Are you sure you want to sign out of ${AppConfig.appName}?', + ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), diff --git a/lib/features/rooms/data/rooms_repository.dart b/lib/features/rooms/data/rooms_repository.dart index 86add7a..a88f590 100644 --- a/lib/features/rooms/data/rooms_repository.dart +++ b/lib/features/rooms/data/rooms_repository.dart @@ -1,10 +1,12 @@ -// Version: 1.0.1 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Rooms repository. Reads room list from the Matrix SDK client. import 'package:matrix/matrix.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; +import '../../../shared/utils/mxc_url.dart'; +import '../../../shared/utils/room_preview.dart'; import '../domain/room_model.dart'; part 'rooms_repository.g.dart'; @@ -49,24 +51,11 @@ class RoomsRepository { return RoomModel( id: room.id, displayName: room.getLocalizedDisplayname(), - avatarUrl: room.avatar?.toString(), - lastMessagePreview: _lastMessagePreview(room), - lastActivityAt: room.timeCreated, + avatarUrl: resolveMxcUrl(_client, room.avatar), + lastMessagePreview: lastMessagePreview(room), + lastActivityAt: room.lastEvent?.originServerTs ?? room.timeCreated, unreadCount: room.notificationCount, - isDirectMessage: room.isDirectChat, isDirect: room.isDirectChat, ); } - - 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, - }; - } } diff --git a/lib/features/rooms/domain/room_model.dart b/lib/features/rooms/domain/room_model.dart index 1891a53..8b0e34f 100644 --- a/lib/features/rooms/domain/room_model.dart +++ b/lib/features/rooms/domain/room_model.dart @@ -1,4 +1,4 @@ -// Version: 1.0.0 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Immutable room model. Wraps the data the rooms screen needs to display. // Derived from the Matrix SDK's Room object in the repository layer. @@ -15,7 +15,6 @@ abstract class RoomModel with _$RoomModel { String? lastMessagePreview, DateTime? lastActivityAt, @Default(0) int unreadCount, - @Default(false) bool isDirectMessage, @Default(false) bool isDirect, }) = _RoomModel; } diff --git a/lib/features/rooms/presentation/room_tile.dart b/lib/features/rooms/presentation/room_tile.dart index 6bad312..ded386f 100644 --- a/lib/features/rooms/presentation/room_tile.dart +++ b/lib/features/rooms/presentation/room_tile.dart @@ -1,10 +1,10 @@ -// Version: 1.0.0 | Created: 2026-04-01 +// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Individual room list tile. Kept under 100 lines. -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:timeago/timeago.dart' as timeago; +import '../../../shared/widgets/matrix_avatar.dart'; import '../domain/room_model.dart'; class RoomTile extends StatelessWidget { @@ -19,7 +19,11 @@ class RoomTile extends StatelessWidget { final hasUnread = room.unreadCount > 0; return ListTile( - leading: _RoomAvatar(room: room), + leading: MatrixAvatar( + name: room.displayName, + avatarUrl: room.avatarUrl, + radius: 24, + ), title: Text( room.displayName, style: theme.textTheme.bodyLarge?.copyWith( @@ -62,40 +66,6 @@ class RoomTile extends StatelessWidget { } } -class _RoomAvatar extends StatelessWidget { - const _RoomAvatar({required this.room}); - - final RoomModel room; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final initials = room.displayName.isNotEmpty - ? room.displayName[0].toUpperCase() - : '?'; - - if (room.avatarUrl != null) { - return CircleAvatar( - radius: 24, - backgroundImage: CachedNetworkImageProvider(room.avatarUrl!), - backgroundColor: theme.colorScheme.surfaceContainerHighest, - ); - } - - return CircleAvatar( - radius: 24, - backgroundColor: theme.colorScheme.primary.withAlpha(51), - child: Text( - initials, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ); - } -} - class _UnreadBadge extends StatelessWidget { const _UnreadBadge({required this.count}); diff --git a/lib/features/rooms/presentation/user_search_dialog.dart b/lib/features/rooms/presentation/user_search_dialog.dart index b9da0af..3c796cc 100644 --- a/lib/features/rooms/presentation/user_search_dialog.dart +++ b/lib/features/rooms/presentation/user_search_dialog.dart @@ -1,7 +1,9 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // User search dialog — search Matrix user directory and start a DM. // Triggered from the rooms screen "New message" button. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -9,6 +11,8 @@ import 'package:matrix/matrix_api_lite.dart' show Profile; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; +import '../../../shared/utils/mxc_url.dart'; +import '../../../shared/widgets/matrix_avatar.dart'; part 'user_search_dialog.g.dart'; @@ -47,13 +51,22 @@ class _UserSearchDialogState extends ConsumerState<_UserSearchDialog> { final _controller = TextEditingController(); String _searchTerm = ''; bool _isStartingDm = false; + Timer? _debounce; @override void dispose() { + _debounce?.cancel(); _controller.dispose(); super.dispose(); } + void _onSearchChanged(String val) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 400), () { + if (mounted) setState(() => _searchTerm = val); + }); + } + Future _startDm(String userId) async { if (_isStartingDm) return; setState(() => _isStartingDm = true); @@ -112,10 +125,10 @@ class _UserSearchDialogState extends ConsumerState<_UserSearchDialog> { controller: _controller, autofocus: true, decoration: const InputDecoration( - hintText: 'Search by name or user ID…', + hintText: 'Search by name or user ID...', prefixIcon: Icon(Icons.search), ), - onChanged: (val) => setState(() => _searchTerm = val), + onChanged: _onSearchChanged, ), ), @@ -160,6 +173,7 @@ class _SearchResults extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final resultsAsync = ref.watch(searchUsersProvider(term)); final theme = Theme.of(context); + final client = ref.watch(matrixClientProvider); return resultsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -186,24 +200,13 @@ class _SearchResults extends ConsumerWidget { 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), - ), - ), + leading: MatrixAvatar( + name: displayName, + avatarUrl: resolveMxcUrl(client, profile.avatarUrl), + radius: 20, + ), title: Text(displayName), subtitle: Text( profile.userId, diff --git a/lib/features/spaces/data/spaces_repository.dart b/lib/features/spaces/data/spaces_repository.dart index 401cc51..2c2450e 100644 --- a/lib/features/spaces/data/spaces_repository.dart +++ b/lib/features/spaces/data/spaces_repository.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // SpacesRepository — builds space list and child rooms from the Matrix SDK. // A space is a room where isSpace == true. @@ -6,6 +6,7 @@ import 'package:matrix/matrix.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; +import '../../../shared/utils/mxc_url.dart'; import '../domain/space_model.dart'; part 'spaces_repository.g.dart'; @@ -46,7 +47,7 @@ class SpacesRepository { SpaceRoomModel( id: room.id, displayName: room.getLocalizedDisplayname(), - avatarUrl: room.avatar?.toString(), + avatarUrl: resolveMxcUrl(_client, room.avatar), isDirect: room.isDirectChat, ), ); @@ -64,7 +65,7 @@ class SpacesRepository { return SpaceModel( id: room.id, displayName: room.getLocalizedDisplayname(), - avatarUrl: room.avatar?.toString(), + avatarUrl: resolveMxcUrl(_client, room.avatar), roomCount: room.spaceChildren.length, ); } diff --git a/lib/features/spaces/presentation/spaces_screen.dart b/lib/features/spaces/presentation/spaces_screen.dart index e402fb2..a1f11a6 100644 --- a/lib/features/spaces/presentation/spaces_screen.dart +++ b/lib/features/spaces/presentation/spaces_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-01 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Spaces screen — list of Matrix spaces with expandable child room lists. import 'package:flutter/material.dart'; @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../shared/widgets/matrix_avatar.dart'; import '../data/spaces_repository.dart'; import '../domain/space_model.dart'; @@ -93,9 +94,10 @@ class _SpaceTileState extends ConsumerState<_SpaceTile> { children: [ // Space header row ListTile( - leading: _SpaceAvatar( + leading: MatrixAvatar( name: space.displayName, avatarUrl: space.avatarUrl, + radius: 22, ), title: Text( space.displayName, @@ -160,7 +162,7 @@ class _ChildRoomList extends ConsumerWidget { children: rooms.map((room) { return ListTile( contentPadding: const EdgeInsets.only(left: 56, right: 16), - leading: _SpaceAvatar( + leading: MatrixAvatar( name: room.displayName, avatarUrl: room.avatarUrl, radius: 18, @@ -180,41 +182,6 @@ class _ChildRoomList extends ConsumerWidget { } } -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- diff --git a/lib/shared/utils/matrix_id.dart b/lib/shared/utils/matrix_id.dart new file mode 100644 index 0000000..1396ee4 --- /dev/null +++ b/lib/shared/utils/matrix_id.dart @@ -0,0 +1,16 @@ +// Version: 1.0.0 | Created: 2026-04-02 +// Utility extension for extracting the localpart from a Matrix user ID. +// Matrix IDs have the form @localpart:server.name — this extracts "localpart". + +/// Extension on [String] for Matrix user ID operations. +extension MatrixIdExtension on String { + /// Extracts the localpart from a Matrix user ID. + /// + /// Example: `'@alice:matrix.m8chat.au'.matrixLocalpart` returns `'alice'`. + /// Returns the original string unchanged if it does not match the expected + /// `@localpart:server` format. + String get matrixLocalpart { + final value = split(':').first.replaceFirst('@', ''); + return value.isEmpty ? this : value; + } +} diff --git a/lib/shared/utils/mxc_url.dart b/lib/shared/utils/mxc_url.dart new file mode 100644 index 0000000..bf99f24 --- /dev/null +++ b/lib/shared/utils/mxc_url.dart @@ -0,0 +1,28 @@ +// Version: 1.0.0 | Created: 2026-04-02 +// Synchronous MXC URI to HTTP URL resolution. +// Avatars and thumbnails in the room list / sync service need a resolved HTTP +// URL. The Matrix SDK's getDownloadUri() is async (checks authenticated media +// support), but for avatar display we need a synchronous result. +// +// This helper builds the legacy v3 media URL which works on all homeservers. + +import 'package:matrix/matrix.dart'; + +/// Resolves an `mxc://` [Uri] to an HTTP download URL using the client's +/// homeserver. Returns `null` if the URI is not an mxc scheme or the client +/// has no homeserver set. +/// +/// This is synchronous — suitable for use in non-async model mapping. +String? resolveMxcUrl(Client client, Uri? mxcUri) { + if (mxcUri == null || !mxcUri.isScheme('mxc')) return null; + final homeserver = client.homeserver; + if (homeserver == null) return null; + + // Build the media download path per the Matrix spec. + final serverName = mxcUri.host; + final port = mxcUri.hasPort ? ':${mxcUri.port}' : ''; + final mediaId = mxcUri.path; // includes leading / + return homeserver + .resolve('_matrix/media/v3/download/$serverName$port$mediaId') + .toString(); +} diff --git a/lib/shared/utils/room_preview.dart b/lib/shared/utils/room_preview.dart new file mode 100644 index 0000000..9371316 --- /dev/null +++ b/lib/shared/utils/room_preview.dart @@ -0,0 +1,19 @@ +// Version: 1.0.0 | Created: 2026-04-02 +// Shared utility for generating a last-message preview string from a Room. +// Used by both RoomsRepository and SyncPersistenceService to avoid duplication. + +import 'package:matrix/matrix.dart'; + +/// Returns a human-readable preview of the room's last event, or null if there +/// is no suitable event to preview. +String? lastMessagePreview(Room room) { + final lastEvent = room.lastEvent; + if (lastEvent == null) return null; + + return switch (lastEvent.type) { + EventTypes.Message => lastEvent.body, + EventTypes.Encrypted => 'Encrypted message', + EventTypes.Sticker => 'Sticker', + _ => null, + }; +} diff --git a/lib/shared/widgets/matrix_avatar.dart b/lib/shared/widgets/matrix_avatar.dart new file mode 100644 index 0000000..37db210 --- /dev/null +++ b/lib/shared/widgets/matrix_avatar.dart @@ -0,0 +1,54 @@ +// Version: 1.0.0 | Created: 2026-04-02 +// Shared avatar widget used throughout the app. Displays a cached network +// image when an HTTP avatar URL is available, or falls back to a coloured +// circle with the first letter of the display name. +// +// The [avatarUrl] MUST be a resolved HTTP URL — never pass an mxc:// URI. + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +class MatrixAvatar extends StatelessWidget { + const MatrixAvatar({ + super.key, + required this.name, + this.avatarUrl, + this.radius = 20, + }); + + /// Display name used for the initials fallback. + final String name; + + /// Resolved HTTP URL for the avatar image. Must NOT be an mxc:// URI. + final String? avatarUrl; + + /// Radius of the [CircleAvatar]. + final double radius; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; + + if (avatarUrl != null) { + return CircleAvatar( + radius: radius, + backgroundImage: CachedNetworkImageProvider(avatarUrl!), + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: theme.colorScheme.primary.withAlpha(51), + child: Text( + initials, + style: TextStyle( + fontSize: radius * 0.7, + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ); + } +}