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:
31
lib/app/app.dart
Normal file
31
lib/app/app.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Root MaterialApp widget. Wires together theme + router.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'router.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Root application widget.
|
||||
///
|
||||
/// [ProviderScope] is set up in main.dart. This widget only handles
|
||||
/// theme and routing — no business logic here.
|
||||
class M8ChatApp extends ConsumerWidget {
|
||||
const M8ChatApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'M8Chat',
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
// Default to dark — M8Chat is a dark-first chat app.
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/app/router.dart
Normal file
115
lib/app/router.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
// Version: 1.0.2 | Created: 2026-04-01
|
||||
// All route definitions in one place.
|
||||
// GoRouter is created ONCE (keepAlive) and re-evaluates redirects via
|
||||
// refreshListenable — avoids the GoRouter recreation bug from ref.watch.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../core/auth/auth_notifier.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../features/auth/presentation/login_screen.dart';
|
||||
import '../features/calls/presentation/call_screen.dart';
|
||||
import '../features/chat/presentation/chat_screen.dart';
|
||||
import '../features/profile/presentation/profile_screen.dart';
|
||||
import '../features/rooms/presentation/rooms_screen.dart';
|
||||
import '../features/spaces/presentation/spaces_screen.dart';
|
||||
|
||||
part 'router.g.dart';
|
||||
|
||||
/// Route path constants — use these instead of raw strings.
|
||||
abstract final class AppRoutes {
|
||||
static const String root = '/';
|
||||
static const String login = '/login';
|
||||
static const String rooms = '/rooms';
|
||||
static const String chat = '/rooms/:roomId';
|
||||
static const String call = '/calls/:roomId';
|
||||
static const String profile = '/profile';
|
||||
static const String spaces = '/spaces';
|
||||
}
|
||||
|
||||
/// ChangeNotifier that GoRouter listens to for redirect re-evaluation.
|
||||
/// Notified by ref.listen whenever authProvider changes.
|
||||
class _AuthRefreshNotifier extends ChangeNotifier {
|
||||
void notify() => notifyListeners();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GoRouter router(Ref ref) {
|
||||
// Create once — notifier triggers re-redirect without recreating the router.
|
||||
final notifier = _AuthRefreshNotifier();
|
||||
ref.listen<AuthState>(authProvider, (_, __) => notifier.notify());
|
||||
ref.onDispose(notifier.dispose);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: AppRoutes.root,
|
||||
refreshListenable: notifier,
|
||||
debugLogDiagnostics: false,
|
||||
redirect: (BuildContext context, GoRouterState state) {
|
||||
// Read (not watch) so redirect does not itself trigger provider changes.
|
||||
final authState = ref.read(authProvider);
|
||||
final isLoggedIn = authState is AuthAuthenticated;
|
||||
final isOnLogin = state.matchedLocation == AppRoutes.login;
|
||||
|
||||
// While restoring session, stay on splash.
|
||||
if (authState is AuthInitial || authState is AuthLoading) return null;
|
||||
|
||||
// Unauthenticated users must go to login.
|
||||
if (!isLoggedIn && !isOnLogin) return AppRoutes.login;
|
||||
|
||||
// Authenticated users should not see the login screen.
|
||||
if (isLoggedIn && isOnLogin) return AppRoutes.rooms;
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.root,
|
||||
builder: (context, state) => const _SplashRedirectPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.login,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.rooms,
|
||||
builder: (context, state) => const RoomsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.chat,
|
||||
builder: (context, state) {
|
||||
// safe: path param is guaranteed by route definition
|
||||
final roomId = state.pathParameters['roomId']!;
|
||||
return ChatScreen(roomId: roomId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.call,
|
||||
builder: (context, state) {
|
||||
// safe: path param is guaranteed by route definition
|
||||
final roomId = state.pathParameters['roomId']!;
|
||||
return CallScreen(roomId: roomId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.profile,
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.spaces,
|
||||
builder: (context, state) => const SpacesScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Invisible page shown at `/` while GoRouter's redirect evaluates auth state.
|
||||
class _SplashRedirectPage extends StatelessWidget {
|
||||
const _SplashRedirectPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
}
|
||||
137
lib/app/theme.dart
Normal file
137
lib/app/theme.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// M8Chat brand theme — dark and light variants.
|
||||
// Primary brand colour: #5C35C9 (deep purple). Accent: #7B5CF6.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// M8Chat brand colours.
|
||||
abstract final class M8Colours {
|
||||
static const Color brandPurple = Color(0xFF5C35C9);
|
||||
static const Color accentPurple = Color(0xFF7B5CF6);
|
||||
static const Color darkBackground = Color(0xFF0F0F1A);
|
||||
static const Color darkSurface = Color(0xFF1A1A2E);
|
||||
static const Color darkSurfaceVariant = Color(0xFF22223A);
|
||||
static const Color onDarkSurface = Color(0xFFE8E8F0);
|
||||
static const Color subtleText = Color(0xFF9898B0);
|
||||
static const Color errorRed = Color(0xFFCF6679);
|
||||
static const Color unreadGreen = Color(0xFF4CAF50);
|
||||
}
|
||||
|
||||
/// Dark theme — default for M8Chat.
|
||||
ThemeData buildDarkTheme() {
|
||||
const seed = M8Colours.brandPurple;
|
||||
|
||||
final scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(
|
||||
surface: M8Colours.darkBackground,
|
||||
surfaceContainerHighest: M8Colours.darkSurface,
|
||||
primary: M8Colours.accentPurple,
|
||||
onSurface: M8Colours.onDarkSurface,
|
||||
error: M8Colours.errorRed,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: M8Colours.darkBackground,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: M8Colours.darkSurface,
|
||||
foregroundColor: M8Colours.onDarkSurface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: M8Colours.darkSurface,
|
||||
indicatorColor: M8Colours.accentPurple.withAlpha(51),
|
||||
labelTextStyle: WidgetStateProperty.all(
|
||||
const TextStyle(fontSize: 12, color: M8Colours.onDarkSurface),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: M8Colours.darkSurfaceVariant,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: M8Colours.accentPurple, width: 2),
|
||||
),
|
||||
labelStyle: const TextStyle(color: M8Colours.subtleText),
|
||||
hintStyle: const TextStyle(color: M8Colours.subtleText),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: M8Colours.accentPurple,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: M8Colours.darkSurface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: M8Colours.darkSurfaceVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: M8Colours.darkSurfaceVariant,
|
||||
contentTextStyle: const TextStyle(color: M8Colours.onDarkSurface),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Light theme — follows system preference if user selects it.
|
||||
ThemeData buildLightTheme() {
|
||||
const seed = M8Colours.brandPurple;
|
||||
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
104
lib/core/auth/auth_notifier.dart
Normal file
104
lib/core/auth/auth_notifier.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Riverpod notifier that owns the auth state machine.
|
||||
// All login/logout/session-restore transitions go through here.
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../features/auth/data/auth_repository.dart';
|
||||
import '../../features/auth/domain/auth_failure.dart';
|
||||
import 'auth_state.dart';
|
||||
import 'secure_storage.dart';
|
||||
|
||||
part 'auth_notifier.g.dart';
|
||||
|
||||
/// The single source of truth for authentication state.
|
||||
///
|
||||
/// keepAlive: true — auth state must persist for the entire app lifetime.
|
||||
/// GoRouter watches this provider to decide which route to show.
|
||||
@Riverpod(keepAlive: true)
|
||||
class AuthNotifier extends _$AuthNotifier {
|
||||
@override
|
||||
AuthState build() {
|
||||
// Kick off session restore immediately; start in [AuthInitial].
|
||||
_restoreSession();
|
||||
return const AuthState.initial();
|
||||
}
|
||||
|
||||
/// Tries to restore a previous session from secure storage.
|
||||
Future<void> _restoreSession() async {
|
||||
state = const AuthState.loading();
|
||||
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final credentials = await storage.loadCredentials();
|
||||
|
||||
if (credentials == null) {
|
||||
state = const AuthState.unauthenticated();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.restoreSession(
|
||||
accessToken: credentials.accessToken,
|
||||
userId: credentials.userId,
|
||||
deviceId: credentials.deviceId,
|
||||
);
|
||||
state = AuthState.authenticated(
|
||||
userId: credentials.userId,
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId,
|
||||
);
|
||||
} on AuthFailure {
|
||||
// Stored credentials are invalid; force re-login.
|
||||
await storage.clearCredentials();
|
||||
state = const AuthState.unauthenticated();
|
||||
} on Exception {
|
||||
// Network offline at startup; still land on login rather than crashing.
|
||||
await storage.clearCredentials();
|
||||
state = const AuthState.unauthenticated();
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to log in with [username] and [password].
|
||||
///
|
||||
/// Transitions: loading → authenticated | unauthenticated(failure).
|
||||
Future<void> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
state = const AuthState.loading();
|
||||
|
||||
try {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
final response = await repo.login(username: username, password: password);
|
||||
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
await storage.saveCredentials(
|
||||
accessToken: response.accessToken,
|
||||
userId: response.userId,
|
||||
deviceId: response.deviceId,
|
||||
);
|
||||
|
||||
state = AuthState.authenticated(
|
||||
userId: response.userId,
|
||||
accessToken: response.accessToken,
|
||||
deviceId: response.deviceId,
|
||||
);
|
||||
} on AuthFailure catch (failure) {
|
||||
state = AuthState.unauthenticated(failure: failure.userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs out the current user, clears storage, and resets to unauthenticated.
|
||||
Future<void> logout() async {
|
||||
state = const AuthState.loading();
|
||||
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.logout();
|
||||
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
await storage.clearCredentials();
|
||||
|
||||
state = const AuthState.unauthenticated();
|
||||
}
|
||||
}
|
||||
29
lib/core/auth/auth_state.dart
Normal file
29
lib/core/auth/auth_state.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Sealed auth state hierarchy.
|
||||
// All auth transitions are expressed as one of these states — no raw booleans.
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth_state.freezed.dart';
|
||||
|
||||
/// Represents every possible authentication state in the app.
|
||||
@freezed
|
||||
sealed class AuthState with _$AuthState {
|
||||
/// App has just launched; trying to restore session from storage.
|
||||
const factory AuthState.initial() = AuthInitial;
|
||||
|
||||
/// A login or session-restore attempt is in progress.
|
||||
const factory AuthState.loading() = AuthLoading;
|
||||
|
||||
/// User is authenticated. Holds the Matrix user ID and access token.
|
||||
const factory AuthState.authenticated({
|
||||
required String userId,
|
||||
required String accessToken,
|
||||
required String deviceId,
|
||||
}) = AuthAuthenticated;
|
||||
|
||||
/// User is not authenticated.
|
||||
/// [failure] is null on first launch; non-null after a failed attempt.
|
||||
const factory AuthState.unauthenticated({String? failure}) =
|
||||
AuthUnauthenticated;
|
||||
}
|
||||
83
lib/core/auth/secure_storage.dart
Normal file
83
lib/core/auth/secure_storage.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Typed wrapper around flutter_secure_storage.
|
||||
// All token read/write operations go through this class — never call
|
||||
// flutter_secure_storage directly from feature code.
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
part 'secure_storage.g.dart';
|
||||
|
||||
/// Provides a configured [SecureStorage] instance.
|
||||
@Riverpod(keepAlive: true)
|
||||
SecureStorage secureStorage(Ref ref) => const SecureStorage();
|
||||
|
||||
/// Typed wrapper around [FlutterSecureStorage].
|
||||
///
|
||||
/// Uses AES encryption on Android and Keychain on iOS.
|
||||
/// On Web, data is stored in localStorage with encryption — acceptable for
|
||||
/// access tokens but NOT for E2EE private keys (Phase 2 concern).
|
||||
class SecureStorage {
|
||||
const SecureStorage();
|
||||
|
||||
static const FlutterSecureStorage _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
Future<void> saveCredentials({
|
||||
required String accessToken,
|
||||
required String userId,
|
||||
required String deviceId,
|
||||
}) async {
|
||||
await _storage.write(
|
||||
key: AppConfig.storageKeyAccessToken,
|
||||
value: accessToken,
|
||||
);
|
||||
await _storage.write(key: AppConfig.storageKeyUserId, value: userId);
|
||||
await _storage.write(key: AppConfig.storageKeyDeviceId, value: deviceId);
|
||||
await _storage.write(
|
||||
key: AppConfig.storageKeyHomeserver,
|
||||
value: AppConfig.matrixBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<StoredCredentials?> loadCredentials() async {
|
||||
final accessToken = await _storage.read(
|
||||
key: AppConfig.storageKeyAccessToken,
|
||||
);
|
||||
final userId = await _storage.read(key: AppConfig.storageKeyUserId);
|
||||
final deviceId = await _storage.read(key: AppConfig.storageKeyDeviceId);
|
||||
|
||||
if (accessToken == null || userId == null || deviceId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return StoredCredentials(
|
||||
accessToken: accessToken,
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearCredentials() async {
|
||||
await _storage.delete(key: AppConfig.storageKeyAccessToken);
|
||||
await _storage.delete(key: AppConfig.storageKeyUserId);
|
||||
await _storage.delete(key: AppConfig.storageKeyDeviceId);
|
||||
await _storage.delete(key: AppConfig.storageKeyHomeserver);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the credentials retrieved from secure storage.
|
||||
class StoredCredentials {
|
||||
const StoredCredentials({
|
||||
required this.accessToken,
|
||||
required this.userId,
|
||||
required this.deviceId,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String userId;
|
||||
final String deviceId;
|
||||
}
|
||||
24
lib/core/config/app_config.dart
Normal file
24
lib/core/config/app_config.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// App-wide constants. Change matrixBaseUrl for different environments.
|
||||
|
||||
/// Central configuration for the M8Chat application.
|
||||
/// All server URLs and app-level constants live here — never scatter
|
||||
/// magic strings through feature code.
|
||||
abstract final class AppConfig {
|
||||
static const String matrixBaseUrl = 'https://matrix.m8chat.au';
|
||||
static const String matrixServerName = 'matrix.m8chat.au';
|
||||
static const String livekitJwtUrl =
|
||||
'https://matrix.m8chat.au/_matrix/livekit/jwt';
|
||||
static const List<String> turnUrls = [
|
||||
'turn:matrix.m8chat.au:3478',
|
||||
'turns:matrix.m8chat.au:5349',
|
||||
];
|
||||
static const String appName = 'M8Chat';
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
// Secure storage key names
|
||||
static const String storageKeyAccessToken = 'access_token';
|
||||
static const String storageKeyUserId = 'user_id';
|
||||
static const String storageKeyDeviceId = 'device_id';
|
||||
static const String storageKeyHomeserver = 'homeserver';
|
||||
}
|
||||
22
lib/core/network/matrix_client.dart
Normal file
22
lib/core/network/matrix_client.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
// Version: 1.0.1 | Created: 2026-04-01
|
||||
// Matrix SDK client singleton provider.
|
||||
// The Matrix client is kept alive for the full app lifetime once initialised.
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
part 'matrix_client.g.dart';
|
||||
|
||||
/// Provides the single [Client] instance used throughout the app.
|
||||
///
|
||||
/// keepAlive: true — the Matrix client must never be disposed while the app
|
||||
/// is running; it holds the sync loop and all room state.
|
||||
///
|
||||
/// Phase 1: no databaseBuilder — uses in-memory store only (no persistence
|
||||
/// across restarts). Phase 2 will wire in a drift-backed DatabaseApi.
|
||||
@Riverpod(keepAlive: true)
|
||||
Client matrixClient(Ref ref) {
|
||||
return Client(AppConfig.appName);
|
||||
}
|
||||
95
lib/features/auth/data/auth_repository.dart
Normal file
95
lib/features/auth/data/auth_repository.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/features/auth/domain/auth_failure.dart
Normal file
41
lib/features/auth/domain/auth_failure.dart
Normal 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',
|
||||
};
|
||||
}
|
||||
39
lib/features/auth/presentation/login_controller.dart
Normal file
39
lib/features/auth/presentation/login_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
241
lib/features/auth/presentation/login_screen.dart
Normal file
241
lib/features/auth/presentation/login_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/features/calls/domain/call_state.dart
Normal file
19
lib/features/calls/domain/call_state.dart
Normal 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;
|
||||
}
|
||||
25
lib/features/calls/presentation/call_controller.dart
Normal file
25
lib/features/calls/presentation/call_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
73
lib/features/calls/presentation/call_screen.dart
Normal file
73
lib/features/calls/presentation/call_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
lib/features/chat/data/chat_repository.dart
Normal file
129
lib/features/chat/data/chat_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
45
lib/features/chat/domain/message_model.dart
Normal file
45
lib/features/chat/domain/message_model.dart
Normal 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;
|
||||
}
|
||||
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
203
lib/features/profile/presentation/profile_screen.dart
Normal file
203
lib/features/profile/presentation/profile_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/features/spaces/presentation/spaces_screen.dart
Normal file
51
lib/features/spaces/presentation/spaces_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/main.dart
Normal file
27
lib/main.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
// Version: 1.0.1 | Created: 2026-04-01
|
||||
// Application entry point.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Catch Flutter framework errors (widget build, rendering) without crashing.
|
||||
FlutterError.onError = (details) {
|
||||
debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}');
|
||||
};
|
||||
|
||||
// Catch all unhandled async errors — including those thrown by the Matrix SDK
|
||||
// in runInRoot() after login (encryption init, sync startup). Without this
|
||||
// handler the browser runtime crashes and the UI freezes on the spinner.
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
debugPrint('[M8Chat] Uncaught error: $error');
|
||||
return true; // returning true marks the error as handled
|
||||
};
|
||||
|
||||
runApp(const ProviderScope(child: M8ChatApp()));
|
||||
}
|
||||
Reference in New Issue
Block a user