- 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>
162 lines
4.3 KiB
Dart
162 lines
4.3 KiB
Dart
// Version: 1.0.0 | Created: 2026-04-01
|
|
// Full chat screen — timeline + message input.
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../core/network/matrix_client.dart';
|
|
import 'chat_controller.dart';
|
|
import 'message_bubble.dart';
|
|
import 'message_input.dart';
|
|
|
|
class ChatScreen extends ConsumerWidget {
|
|
const ChatScreen({super.key, required this.roomId});
|
|
|
|
final String roomId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
// Decode the roomId — GoRouter encodes ! as %21 etc.
|
|
final decodedRoomId = Uri.decodeComponent(roomId);
|
|
|
|
final client = ref.watch(matrixClientProvider);
|
|
final room = client.getRoomById(decodedRoomId);
|
|
final roomName = room?.getLocalizedDisplayname() ?? 'Chat';
|
|
final roomAvatar = room?.avatar?.toString();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
titleSpacing: 0,
|
|
title: Row(
|
|
children: [
|
|
_RoomAvatarSmall(name: roomName, avatarUrl: roomAvatar),
|
|
const SizedBox(width: 10),
|
|
Flexible(child: Text(roomName, overflow: TextOverflow.ellipsis)),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.call),
|
|
tooltip: 'Start call (Phase 2)',
|
|
onPressed: null, // Phase 2
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.more_vert),
|
|
tooltip: 'Room options',
|
|
onPressed: () {
|
|
// Phase 2: room settings sheet
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(child: _Timeline(roomId: decodedRoomId)),
|
|
_Input(roomId: decodedRoomId),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Timeline extends ConsumerWidget {
|
|
const _Timeline({required this.roomId});
|
|
|
|
final String roomId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final timelineAsync = ref.watch(chatTimelineProvider(roomId));
|
|
|
|
return timelineAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, _) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text('Could not load messages: $error'),
|
|
),
|
|
),
|
|
data: (messages) {
|
|
if (messages.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'No messages yet. Say hello!',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withAlpha(102),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
reverse: true,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, index) {
|
|
return MessageBubble(message: messages[index]);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Input extends ConsumerWidget {
|
|
const _Input({required this.roomId});
|
|
|
|
final String roomId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final isSending = ref.watch(sendMessageProvider);
|
|
|
|
return MessageInput(
|
|
isSending: isSending,
|
|
onSend: (text) async {
|
|
final error = await ref
|
|
.read(sendMessageProvider.notifier)
|
|
.send(roomId, text);
|
|
|
|
if (error != null && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to send message: $error'),
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|