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:
2026-04-02 06:26:57 +10:00
commit 8f13c725a4
114 changed files with 4336 additions and 0 deletions

View 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,
};
}
}

View 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;
}

View 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,
),
),
);
}
}

View 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();
}

View 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),
),
),
],
),
),
);
}
}