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,95 @@
// Version: 1.0.3 | Created: 2026-04-01
// Auth repository: handles all Matrix login/logout API interactions.
// Uses the Matrix Dart SDK — no raw HTTP calls for auth.
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart';
import '../domain/auth_failure.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(Ref ref) {
return AuthRepository(client: ref.watch(matrixClientProvider));
}
/// Handles authentication interactions with the Matrix homeserver.
class AuthRepository {
AuthRepository({required Client client}) : _client = client;
final Client _client;
/// Attempts password login. Returns [LoginResponse] on success,
/// throws [AuthFailure] on failure.
///
/// Matrix error codes mapped:
/// M_FORBIDDEN → [InvalidCredentials]
/// M_USER_DEACTIVATED → [AccountDisabled]
/// Network error → [NetworkError]
Future<LoginResponse> login({
required String username,
required String password,
}) async {
try {
// Set homeserver directly — avoids network round-trips and version checks
// that checkHomeserver() makes. The setter is public in matrix 0.33.0.
_client.homeserver = Uri.parse(AppConfig.matrixBaseUrl);
return await _client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: username),
password: password,
initialDeviceDisplayName: 'M8Chat',
);
} on MatrixException catch (e) {
throw switch (e.errcode) {
'M_FORBIDDEN' => const AuthFailure.invalidCredentials(),
'M_USER_DEACTIVATED' => const AuthFailure.accountDisabled(),
_ => AuthFailure.serverError(
statusCode: e.response?.statusCode,
message: e.errorMessage,
),
};
} on Exception catch (e) {
// Covers SocketException, TimeoutException, etc.
final msg = e.toString().toLowerCase();
if (msg.contains('socket') ||
msg.contains('connection') ||
msg.contains('host lookup') ||
msg.contains('timeout')) {
throw AuthFailure.networkError(message: e.toString());
}
throw AuthFailure.unknown(message: e.toString());
}
}
/// Logs out the current session on the homeserver.
/// Silently succeeds if the token is already invalid (network-first logout).
Future<void> logout() async {
try {
await _client.logout();
} on MatrixException {
// Token already invalid — treat as successful logout.
} on Exception {
// Network offline — proceed with local cleanup regardless.
}
}
/// Restores an existing Matrix session using a stored access token.
Future<void> restoreSession({
required String accessToken,
required String userId,
required String deviceId,
}) async {
await _client.init(
newToken: accessToken,
newUserID: userId,
newDeviceID: deviceId,
newDeviceName: 'M8Chat',
newHomeserver: Uri.parse(AppConfig.matrixBaseUrl),
newOlmAccount: null,
);
}
}

View File

@@ -0,0 +1,41 @@
// Version: 1.0.0 | Created: 2026-04-01
// Typed failure hierarchy for authentication errors.
// UI maps these to human-readable messages — never expose raw exception text.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_failure.freezed.dart';
/// All ways authentication can fail.
@freezed
sealed class AuthFailure with _$AuthFailure {
/// Username or password was incorrect.
const factory AuthFailure.invalidCredentials() = InvalidCredentials;
/// Could not reach the Matrix homeserver.
const factory AuthFailure.networkError({String? message}) = NetworkError;
/// The server returned an unexpected response.
const factory AuthFailure.serverError({int? statusCode, String? message}) =
ServerError;
/// User's account has been disabled or deactivated.
const factory AuthFailure.accountDisabled() = AccountDisabled;
/// An unexpected error that doesn't fit the categories above.
const factory AuthFailure.unknown({required String message}) = UnknownFailure;
}
/// Maps an [AuthFailure] to a user-facing string (Australian English).
extension AuthFailureMessage on AuthFailure {
String get userMessage => switch (this) {
InvalidCredentials() => 'Incorrect username or password. Please try again.',
NetworkError() =>
'Could not connect to the server. Check your internet connection and try again.',
ServerError(statusCode: final code) =>
'The server returned an error${code != null ? ' (code $code)' : ''}. Please try again shortly.',
AccountDisabled() =>
'Your account has been disabled. Please contact the administrator.',
UnknownFailure(message: final msg) => 'Something went wrong: $msg',
};
}

View File

@@ -0,0 +1,39 @@
// Version: 1.0.0 | Created: 2026-04-01
// Riverpod controller for the login form.
// Owns the loading state visible to the login screen widget.
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_state.dart';
part 'login_controller.g.dart';
@riverpod
class LoginController extends _$LoginController {
@override
bool build() => false; // isLoading
/// Delegates to [AuthNotifier.login]. Returns the failure message if any,
/// or null on success.
Future<String?> login({
required String username,
required String password,
}) async {
state = true;
try {
await ref
.read(authProvider.notifier)
.login(username: username, password: password);
// Check if auth failed.
final authState = ref.read(authProvider);
return authState.maybeWhen(
unauthenticated: (failure) => failure,
orElse: () => null,
);
} finally {
state = false;
}
}
}

View File

@@ -0,0 +1,241 @@
// Version: 1.0.0 | Created: 2026-04-01
// 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 'login_controller.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _passwordVisible = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final failure = await ref
.read(loginControllerProvider.notifier)
.login(
username: _usernameController.text.trim(),
password: _passwordController.text,
);
if (failure != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
@override
Widget build(BuildContext context) {
final isLoading = ref.watch(loginControllerProvider);
final theme = Theme.of(context);
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_LogoSection(theme: theme),
const SizedBox(height: 48),
_UsernameField(controller: _usernameController),
const SizedBox(height: 16),
_PasswordField(
controller: _passwordController,
isVisible: _passwordVisible,
onToggleVisibility: () {
setState(() => _passwordVisible = !_passwordVisible);
},
onSubmit: _submit,
),
const SizedBox(height: 32),
_SignInButton(isLoading: isLoading, onPressed: _submit),
const SizedBox(height: 16),
_ServerLabel(theme: theme),
],
),
),
),
),
),
),
);
}
}
class _LogoSection extends StatelessWidget {
const _LogoSection({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Column(
children: [
Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
errorBuilder: (_, __, ___) => CircleAvatar(
radius: 48,
backgroundColor: theme.colorScheme.primary,
child: const Icon(Icons.chat_bubble, size: 48, color: Colors.white),
),
),
const SizedBox(height: 20),
Text(
'M8Chat',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
'Sign in to continue',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
);
}
}
class _UsernameField extends StatelessWidget {
const _UsernameField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
prefixIcon: Icon(Icons.person_outline),
),
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your username.';
}
return null;
},
);
}
}
class _PasswordField extends StatelessWidget {
const _PasswordField({
required this.controller,
required this.isVisible,
required this.onToggleVisibility,
required this.onSubmit,
});
final TextEditingController controller;
final bool isVisible;
final VoidCallback onToggleVisibility;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(isVisible ? Icons.visibility_off : Icons.visibility),
onPressed: onToggleVisibility,
tooltip: isVisible ? 'Hide password' : 'Show password',
),
),
obscureText: !isVisible,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password.';
}
return null;
},
);
}
}
class _SignInButton extends StatelessWidget {
const _SignInButton({required this.isLoading, required this.onPressed});
final bool isLoading;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text('Sign In'),
);
}
}
class _ServerLabel extends StatelessWidget {
const _ServerLabel({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Text(
'matrix.m8chat.au',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
);
}
}

View File

@@ -0,0 +1,19 @@
// Version: 1.0.0 | Created: 2026-04-01
// Call state sealed hierarchy. LiveKit integration in Phase 2.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'call_state.freezed.dart';
@freezed
sealed class CallState with _$CallState {
const factory CallState.idle() = CallIdle;
const factory CallState.connecting({required String roomId}) = CallConnecting;
const factory CallState.active({
required String roomId,
required Duration duration,
@Default(false) bool isVideoEnabled,
@Default(true) bool isAudioEnabled,
}) = CallActive;
const factory CallState.ended({String? reason}) = CallEnded;
}

View File

@@ -0,0 +1,25 @@
// Version: 1.0.0 | Created: 2026-04-01
// Call controller stub. LiveKit integration deferred to Phase 2.
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/call_state.dart';
part 'call_controller.g.dart';
@Riverpod(keepAlive: false)
class CallController extends _$CallController {
@override
CallState build() => const CallState.idle();
/// Phase 2: join a LiveKit room via MatrixRTC JWT endpoint.
Future<void> joinCall(String roomId) async {
state = CallState.connecting(roomId: roomId);
// TODO(phase2): fetch JWT from AppConfig.livekitJwtUrl and connect LiveKit client.
state = const CallState.ended(reason: 'Calls not yet implemented.');
}
Future<void> endCall() async {
state = const CallState.ended();
}
}

View File

@@ -0,0 +1,73 @@
// Version: 1.0.0 | Created: 2026-04-01
// Call screen skeleton. Phase 2 will wire in LiveKit video/audio.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../domain/call_state.dart';
import 'call_controller.dart';
class CallScreen extends ConsumerWidget {
const CallScreen({super.key, required this.roomId});
final String roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callControllerProvider);
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.videocam_off_outlined,
size: 80,
color: Colors.white.withAlpha(153),
),
const SizedBox(height: 16),
Text(
switch (callState) {
CallConnecting() => 'Connecting...',
CallEnded(:final reason) => reason ?? 'Call ended.',
_ => 'Call (Phase 2)',
},
style: const TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 8),
Text(
'Video calls will be available in the next release.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withAlpha(153),
fontSize: 14,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(32),
child: FloatingActionButton(
backgroundColor: Colors.red,
onPressed: () {
ref.read(callControllerProvider.notifier).endCall();
context.pop();
},
child: const Icon(Icons.call_end, color: Colors.white),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,129 @@
// Version: 1.0.1 | Created: 2026-04-01
// Chat repository. Bridges Matrix SDK timeline to app domain models.
// Uses room.getTimeline() — timeline is async in matrix 0.33.0.
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/matrix_client.dart';
import '../domain/message_model.dart';
part 'chat_repository.g.dart';
@riverpod
ChatRepository chatRepository(Ref ref) {
return ChatRepository(client: ref.watch(matrixClientProvider));
}
class ChatRepository {
ChatRepository({required Client client}) : _client = client;
final Client _client;
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
/// Returns a stream of message lists for [roomId].
///
/// Opens the room's timeline once and then emits on every update.
/// The timeline object is closed when the stream subscription is cancelled.
Stream<List<MessageModel>> watchTimeline(String roomId) async* {
final room = _getRoom(roomId);
if (room == null) return;
final timeline = await room.getTimeline(
onUpdate: () {
// Handled by the stream controller below.
},
);
// Emit the initial state.
yield _mapTimeline(timeline, room);
// Emit on subsequent sync events that affect this room.
await for (final update in _client.onSync.stream) {
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
if (updatesThisRoom) {
yield _mapTimeline(timeline, room);
}
}
// Clean up timeline subscriptions when the stream is cancelled.
timeline.cancelSubscriptions();
}
/// Sends a plain text message to [roomId].
Future<void> sendTextMessage(String roomId, String text) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendTextEvent(text);
}
/// Sends a read receipt for the latest event in [roomId].
Future<void> markAsRead(String roomId) async {
final room = _getRoom(roomId);
if (room == null) return;
final lastEventId = room.lastEvent?.eventId ?? '';
if (lastEventId.isEmpty) return;
await room.setReadMarker(lastEventId, mRead: lastEventId);
}
/// Requests older messages be loaded (pagination).
Future<void> loadMoreMessages(String roomId) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.requestHistory();
}
List<MessageModel> _mapTimeline(Timeline timeline, Room room) {
final myUserId = _client.userID ?? '';
return timeline.events
.map((e) => _toModel(e, timeline, myUserId))
.toList()
.reversed
.toList();
}
MessageModel _toModel(Event event, Timeline timeline, String myUserId) {
final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback(
event.senderId,
);
return MessageModel(
eventId: event.eventId,
roomId: event.roomId ?? '',
senderId: event.senderId,
senderDisplayName:
senderProfile.displayName ?? event.senderId.split(':').first,
senderAvatarUrl: senderProfile.avatarUrl?.toString(),
timestamp: event.originServerTs,
type: _messageType(event),
body: event.redacted ? null : event.body,
mxcUrl: _extractMxcUrl(event),
inReplyToEventId: event.relationshipEventId,
isMine: event.senderId == myUserId,
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
);
}
MessageType _messageType(Event event) {
if (event.redacted) return MessageType.redacted;
return switch (event.messageType) {
MessageTypes.Text => MessageType.text,
MessageTypes.Image => MessageType.image,
MessageTypes.File => MessageType.file,
MessageTypes.Audio => MessageType.audio,
MessageTypes.Video => MessageType.video,
MessageTypes.Sticker => MessageType.sticker,
_ => MessageType.unsupported,
};
}
String? _extractMxcUrl(Event event) {
final content = event.content;
if (content.containsKey('url')) {
return content['url'] as String?;
}
return null;
}
}

View File

@@ -0,0 +1,45 @@
// Version: 1.0.0 | Created: 2026-04-01
// Immutable message model for the chat timeline.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'message_model.freezed.dart';
/// Content type of a message event.
enum MessageType {
text,
image,
file,
audio,
video,
sticker,
redacted,
unsupported,
}
@freezed
abstract class MessageModel with _$MessageModel {
const factory MessageModel({
required String eventId,
required String roomId,
required String senderId,
required String senderDisplayName,
String? senderAvatarUrl,
required DateTime timestamp,
required MessageType type,
// Text content (for text messages).
String? body,
// URL for media messages.
String? mediaUrl,
// MXC URI for the media (used to download from homeserver).
String? mxcUrl,
// If this is a reply, the event ID of the original message.
String? inReplyToEventId,
// Reactions: emoji → list of sender IDs.
@Default({}) Map<String, List<String>> reactions,
// Read receipts: sender IDs of users who have read up to this event.
@Default([]) List<String> readByUserIds,
@Default(false) bool isEdited,
@Default(false) bool isMine,
}) = _MessageModel;
}

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

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

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

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

View File

@@ -0,0 +1,203 @@
// Version: 1.0.1 | Created: 2026-04-01
// Profile screen. Shows current user info and logout button.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_state.dart';
import '../../../core/network/matrix_client.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key, this.embedded = false});
/// When true, this screen is shown inside the bottom-nav tab of RoomsScreen.
final bool embedded;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final client = ref.watch(matrixClientProvider);
final userId = authState.maybeWhen(
authenticated: (userId, _, __) => userId,
orElse: () => '',
);
final displayName = client.userID != null
? (client.userID!.split(':').first.replaceFirst('@', ''))
: 'Unknown';
final body = ListView(
padding: const EdgeInsets.all(24),
children: [
_ProfileAvatar(displayName: displayName),
const SizedBox(height: 16),
Center(
child: Text(
'@$displayName',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Center(
child: Text(
userId,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
),
),
),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
Text(
'Settings',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 8),
ExpansionTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Notifications'),
children: [
SwitchListTile(
title: const Text('Push notifications'),
subtitle: const Text('Coming in Phase 2'),
value: false,
onChanged: null,
),
SwitchListTile(
title: const Text('Notification sounds'),
subtitle: const Text('Coming in Phase 2'),
value: false,
onChanged: null,
),
],
),
ExpansionTile(
leading: const Icon(Icons.security_outlined),
title: const Text('Security & Privacy'),
children: [
ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('End-to-end encryption'),
subtitle: const Text('Active — messages are encrypted'),
enabled: false,
),
ListTile(
leading: const Icon(Icons.verified_user_outlined),
title: const Text('Verify devices'),
subtitle: const Text('Cross-signing setup — coming in Phase 2'),
enabled: false,
),
ListTile(
leading: const Icon(Icons.password_outlined),
title: const Text('Change password'),
subtitle: const Text('Managed via m8chat.au account settings'),
enabled: false,
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
_LogoutButton(
onLogout: () async {
await ref.read(authProvider.notifier).logout();
},
),
const SizedBox(height: 16),
Center(
child: Text(
'M8Chat 1.0.0 · matrix.m8chat.au',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(77),
),
),
),
],
);
if (embedded) return body;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: body,
);
}
}
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});
final VoidCallback onLogout;
@override
Widget build(BuildContext context) {
return OutlinedButton.icon(
onPressed: () {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Sign out'),
content: const Text('Are you sure you want to sign out of M8Chat?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onLogout();
},
child: Text(
'Sign out',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
);
},
icon: const Icon(Icons.logout),
label: const Text('Sign out'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(color: Theme.of(context).colorScheme.error),
minimumSize: const Size.fromHeight(48),
),
);
}
}

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

View File

@@ -0,0 +1,51 @@
// Version: 1.0.0 | Created: 2026-04-01
// Spaces screen stub — Phase 2 will implement full spaces navigation.
import 'package:flutter/material.dart';
class SpacesScreen extends StatelessWidget {
const SpacesScreen({super.key, this.embedded = false});
final bool embedded;
@override
Widget build(BuildContext context) {
final body = Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.dashboard_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurface.withAlpha(77),
),
const SizedBox(height: 16),
Text(
'Spaces',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 8),
Text(
'Space navigation is coming in Phase 2.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha(102),
),
),
],
),
),
);
if (embedded) return body;
return Scaffold(
appBar: AppBar(title: const Text('Spaces')),
body: body,
);
}
}