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:
36
lib/features/chat/presentation/chat_controller.dart
Normal file
36
lib/features/chat/presentation/chat_controller.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Riverpod providers for chat timeline.
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../data/chat_repository.dart';
|
||||
import '../domain/message_model.dart';
|
||||
|
||||
part 'chat_controller.g.dart';
|
||||
|
||||
/// Streams the message list for [roomId].
|
||||
@riverpod
|
||||
Stream<List<MessageModel>> chatTimeline(Ref ref, String roomId) {
|
||||
final repo = ref.watch(chatRepositoryProvider);
|
||||
return repo.watchTimeline(roomId);
|
||||
}
|
||||
|
||||
/// Sends a text message. Returns an error string on failure, null on success.
|
||||
@riverpod
|
||||
class SendMessage extends _$SendMessage {
|
||||
@override
|
||||
bool build() => false; // isSending
|
||||
|
||||
Future<String?> send(String roomId, String text) async {
|
||||
if (text.trim().isEmpty) return null;
|
||||
state = true;
|
||||
try {
|
||||
await ref.read(chatRepositoryProvider).sendTextMessage(roomId, text);
|
||||
return null;
|
||||
} on Exception catch (e) {
|
||||
return e.toString();
|
||||
} finally {
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
161
lib/features/chat/presentation/chat_screen.dart
Normal file
161
lib/features/chat/presentation/chat_screen.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
286
lib/features/chat/presentation/message_bubble.dart
Normal file
286
lib/features/chat/presentation/message_bubble.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// 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 '../domain/message_model.dart';
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({super.key, required this.message});
|
||||
|
||||
final MessageModel message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isMine = message.isMine;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
left: isMine ? 48 : 8,
|
||||
right: isMine ? 8 : 48,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: isMine
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMine) ...[
|
||||
_SenderAvatar(message: message),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: isMine
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMine)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 2),
|
||||
child: Text(
|
||||
message.senderDisplayName,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
_BubbleContent(message: message, isMine: isMine),
|
||||
if (message.reactions.isNotEmpty)
|
||||
_ReactionsRow(reactions: message.reactions),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
|
||||
final MessageModel message;
|
||||
final bool isMine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final bgColour = isMine
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest;
|
||||
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColour,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMine ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMine ? 4 : 16),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_MessageContentBody(message: message, textColour: textColour),
|
||||
const SizedBox(height: 2),
|
||||
_Timestamp(
|
||||
timestamp: message.timestamp,
|
||||
isEdited: message.isEdited,
|
||||
textColour: textColour.withAlpha(153),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageContentBody extends StatelessWidget {
|
||||
const _MessageContentBody({required this.message, required this.textColour});
|
||||
|
||||
final MessageModel message;
|
||||
final Color textColour;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (message.type) {
|
||||
MessageType.text => Text(
|
||||
message.body ?? '',
|
||||
style: TextStyle(color: textColour),
|
||||
),
|
||||
MessageType.image => _ImageContent(message: message),
|
||||
MessageType.file => _FileContent(
|
||||
message: message,
|
||||
textColour: textColour,
|
||||
),
|
||||
MessageType.redacted => Text(
|
||||
'This message was deleted.',
|
||||
style: TextStyle(
|
||||
color: textColour.withAlpha(153),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
_ => Text(
|
||||
message.body ?? 'Unsupported message type',
|
||||
style: TextStyle(
|
||||
color: textColour.withAlpha(153),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageContent extends StatelessWidget {
|
||||
const _ImageContent({required this.message});
|
||||
|
||||
final MessageModel message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.mediaUrl != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: message.mediaUrl!,
|
||||
width: 240,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => const SizedBox(
|
||||
height: 160,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorWidget: (_, __, ___) => const SizedBox(
|
||||
height: 80,
|
||||
child: Center(child: Icon(Icons.broken_image)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Icon(Icons.image_not_supported);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileContent extends StatelessWidget {
|
||||
const _FileContent({required this.message, required this.textColour});
|
||||
|
||||
final MessageModel message;
|
||||
final Color textColour;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.attach_file, color: textColour, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
message.body ?? 'File',
|
||||
style: TextStyle(color: textColour),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Timestamp extends StatelessWidget {
|
||||
const _Timestamp({
|
||||
required this.timestamp,
|
||||
required this.isEdited,
|
||||
required this.textColour,
|
||||
});
|
||||
|
||||
final DateTime timestamp;
|
||||
final bool isEdited;
|
||||
final Color textColour;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formatted = DateFormat('HH:mm').format(timestamp.toLocal());
|
||||
final label = isEdited ? '$formatted (edited)' : formatted;
|
||||
|
||||
return Text(label, style: TextStyle(fontSize: 10, color: textColour));
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionsRow extends StatelessWidget {
|
||||
const _ReactionsRow({required this.reactions});
|
||||
|
||||
final Map<String, List<String>> reactions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
children: reactions.entries.map((entry) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withAlpha(51),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${entry.key} ${entry.value.length}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/features/chat/presentation/message_input.dart
Normal file
115
lib/features/chat/presentation/message_input.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Message input bar. Text field + send button.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MessageInput extends StatefulWidget {
|
||||
const MessageInput({
|
||||
super.key,
|
||||
required this.onSend,
|
||||
required this.isSending,
|
||||
});
|
||||
|
||||
final Future<void> Function(String text) onSend;
|
||||
final bool isSending;
|
||||
|
||||
@override
|
||||
State<MessageInput> createState() => _MessageInputState();
|
||||
}
|
||||
|
||||
class _MessageInputState extends State<MessageInput> {
|
||||
final _controller = TextEditingController();
|
||||
bool _hasText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() {
|
||||
final hasText = _controller.text.trim().isNotEmpty;
|
||||
if (hasText != _hasText) {
|
||||
setState(() => _hasText = hasText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
await widget.onSend(text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.colorScheme.outline.withAlpha(51)),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Attach file (Phase 2)',
|
||||
onPressed: null, // Phase 2
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Message',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: widget.isSending
|
||||
? const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
onPressed: _hasText ? _submit : null,
|
||||
color: _hasText
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withAlpha(77),
|
||||
tooltip: 'Send message',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user