feat: Phase 1 complete — Matrix login, rooms, chat, profile
- Direct m.login.password auth against matrix.m8chat.au - Room list with unread badges, last message, timestamps - Chat timeline (text, images, files, replies, reactions) - Profile screen with expandable Notifications and Security sections - Olm E2EE initialisation (web WASM bootstrap) - Global error handler preventing Matrix SDK crashes - GoRouter with refreshListenable (no recreation on auth change) - Feature-first clean architecture: Riverpod + GoRouter + Drift - Deployed to https://app2.m8chat.au Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
72
lib/features/rooms/data/rooms_repository.dart
Normal file
72
lib/features/rooms/data/rooms_repository.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
// Version: 1.0.1 | Created: 2026-04-01
|
||||
// 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 '../domain/room_model.dart';
|
||||
|
||||
part 'rooms_repository.g.dart';
|
||||
|
||||
@riverpod
|
||||
RoomsRepository roomsRepository(Ref ref) {
|
||||
return RoomsRepository(client: ref.watch(matrixClientProvider));
|
||||
}
|
||||
|
||||
class RoomsRepository {
|
||||
RoomsRepository({required Client client}) : _client = client;
|
||||
|
||||
final Client _client;
|
||||
|
||||
/// Returns the current room list, sorted unread-first then by last activity.
|
||||
List<RoomModel> getRooms() {
|
||||
final rooms = _client.rooms;
|
||||
final models = rooms.map(_toModel).toList();
|
||||
|
||||
models.sort((a, b) {
|
||||
// Unread rooms first.
|
||||
if (a.unreadCount != b.unreadCount) {
|
||||
return b.unreadCount.compareTo(a.unreadCount);
|
||||
}
|
||||
// Then by most recent activity.
|
||||
final aTime = a.lastActivityAt ?? DateTime(0);
|
||||
final bTime = b.lastActivityAt ?? DateTime(0);
|
||||
return bTime.compareTo(aTime);
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/// Emits current rooms immediately, then re-emits on every sync.
|
||||
/// Immediate yield prevents indefinite spinner while waiting for first sync.
|
||||
Stream<List<RoomModel>> watchRooms() async* {
|
||||
yield getRooms();
|
||||
yield* _client.onSync.stream.map((_) => getRooms());
|
||||
}
|
||||
|
||||
RoomModel _toModel(Room room) {
|
||||
return RoomModel(
|
||||
id: room.id,
|
||||
displayName: room.getLocalizedDisplayname(),
|
||||
avatarUrl: room.avatar?.toString(),
|
||||
lastMessagePreview: _lastMessagePreview(room),
|
||||
lastActivityAt: 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
lib/features/rooms/domain/room_model.dart
Normal file
21
lib/features/rooms/domain/room_model.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Immutable room model. Wraps the data the rooms screen needs to display.
|
||||
// Derived from the Matrix SDK's Room object in the repository layer.
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'room_model.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class RoomModel with _$RoomModel {
|
||||
const factory RoomModel({
|
||||
required String id,
|
||||
required String displayName,
|
||||
String? avatarUrl,
|
||||
String? lastMessagePreview,
|
||||
DateTime? lastActivityAt,
|
||||
@Default(0) int unreadCount,
|
||||
@Default(false) bool isDirectMessage,
|
||||
@Default(false) bool isDirect,
|
||||
}) = _RoomModel;
|
||||
}
|
||||
123
lib/features/rooms/presentation/room_tile.dart
Normal file
123
lib/features/rooms/presentation/room_tile.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// 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 '../domain/room_model.dart';
|
||||
|
||||
class RoomTile extends StatelessWidget {
|
||||
const RoomTile({super.key, required this.room, required this.onTap});
|
||||
|
||||
final RoomModel room;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hasUnread = room.unreadCount > 0;
|
||||
|
||||
return ListTile(
|
||||
leading: _RoomAvatar(room: room),
|
||||
title: Text(
|
||||
room.displayName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: hasUnread ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: room.lastMessagePreview != null
|
||||
? Text(
|
||||
room.lastMessagePreview!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (room.lastActivityAt != null)
|
||||
Text(
|
||||
timeago.format(room.lastActivityAt!, locale: 'en_short'),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: hasUnread
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withAlpha(102),
|
||||
),
|
||||
),
|
||||
if (hasUnread) ...[
|
||||
const SizedBox(height: 4),
|
||||
_UnreadBadge(count: room.unreadCount),
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = count > 99 ? '99+' : count.toString();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/features/rooms/presentation/rooms_controller.dart
Normal file
16
lib/features/rooms/presentation/rooms_controller.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Riverpod provider that streams the rooms list to the UI.
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../data/rooms_repository.dart';
|
||||
import '../domain/room_model.dart';
|
||||
|
||||
part 'rooms_controller.g.dart';
|
||||
|
||||
/// Streams the rooms list. Rebuilds whenever Matrix sync produces changes.
|
||||
@riverpod
|
||||
Stream<List<RoomModel>> roomsList(Ref ref) {
|
||||
final repo = ref.watch(roomsRepositoryProvider);
|
||||
return repo.watchRooms();
|
||||
}
|
||||
178
lib/features/rooms/presentation/rooms_screen.dart
Normal file
178
lib/features/rooms/presentation/rooms_screen.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Main rooms list screen with bottom navigation.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../profile/presentation/profile_screen.dart';
|
||||
import '../../spaces/presentation/spaces_screen.dart';
|
||||
import 'room_tile.dart';
|
||||
import 'rooms_controller.dart';
|
||||
|
||||
class RoomsScreen extends ConsumerStatefulWidget {
|
||||
const RoomsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RoomsScreen> createState() => _RoomsScreenState();
|
||||
}
|
||||
|
||||
class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
static const _destinations = [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: 'Rooms',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Spaces',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: 'Profile',
|
||||
),
|
||||
];
|
||||
|
||||
Widget _buildBody() {
|
||||
return switch (_selectedIndex) {
|
||||
0 => const _RoomListBody(),
|
||||
1 => const SpacesScreen(embedded: true),
|
||||
2 => const ProfileScreen(embedded: true),
|
||||
_ => const _RoomListBody(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: _selectedIndex == 0
|
||||
? AppBar(
|
||||
title: const Text('M8Chat'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search rooms',
|
||||
onPressed: () {
|
||||
// Phase 2: room search
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_square),
|
||||
tooltip: 'New message',
|
||||
onPressed: () {
|
||||
// Phase 2: start a new DM or group chat
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
body: _buildBody(),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) =>
|
||||
setState(() => _selectedIndex = index),
|
||||
destinations: _destinations,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomListBody extends ConsumerWidget {
|
||||
const _RoomListBody();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final roomsAsync = ref.watch(roomsListProvider);
|
||||
|
||||
return roomsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Could not load rooms.',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.refresh(roomsListProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (rooms) {
|
||||
if (rooms.isEmpty) {
|
||||
return const _EmptyRoomsState();
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: rooms.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1, indent: 72),
|
||||
itemBuilder: (context, index) {
|
||||
final room = rooms[index];
|
||||
return RoomTile(
|
||||
room: room,
|
||||
onTap: () =>
|
||||
context.push('/rooms/${Uri.encodeComponent(room.id)}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyRoomsState extends StatelessWidget {
|
||||
const _EmptyRoomsState();
|
||||
|
||||
@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.chat_bubble_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withAlpha(77),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No rooms yet',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Rooms you join will appear here.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(102),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user