diff --git a/lib/core/matrix_client.dart b/lib/core/matrix_client.dart new file mode 100644 index 0000000..0a289eb --- /dev/null +++ b/lib/core/matrix_client.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +final matrixClientProvider = FutureProvider((ref) async { + if (!kIsWeb && (Platform.isWindows || Platform.isLinux)) { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + } + + final appDir = await getApplicationSupportDirectory(); + final dbPath = p.join(appDir.path, 'pyramid.sqlite'); + + final db = await databaseFactoryFfi.openDatabase(dbPath); + final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); + + final client = Client('Pyramid', database: sdkDb); + await client.init(); + return client; +}); + +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorage(); +}); diff --git a/lib/core/router.dart b/lib/core/router.dart index e9b442c..6f0ff4c 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -1,14 +1,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:pyramid/layout/shell_page.dart'; -import 'package:pyramid/features/rooms/rooms_page.dart'; +import 'package:pyramid/features/auth/login_notifier.dart'; +import 'package:pyramid/features/auth/login_page.dart'; import 'package:pyramid/features/chat/chat_page.dart'; +import 'package:pyramid/features/rooms/rooms_page.dart'; import 'package:pyramid/features/settings/settings_page.dart'; +import 'package:pyramid/layout/shell_page.dart'; final routerProvider = Provider((ref) { + final isLoggedIn = ref.watch(isLoggedInProvider); + return GoRouter( initialLocation: '/rooms', + redirect: (context, state) { + final loggingIn = state.matchedLocation == '/login'; + if (!isLoggedIn && !loggingIn) return '/login'; + if (isLoggedIn && loggingIn) return '/rooms'; + return null; + }, routes: [ + GoRoute( + path: '/login', + builder: (context, state) => const LoginPage(), + ), ShellRoute( builder: (context, state, child) => ShellPage(child: child), routes: [ diff --git a/lib/features/auth/login_notifier.dart b/lib/features/auth/login_notifier.dart new file mode 100644 index 0000000..aa9d9f1 --- /dev/null +++ b/lib/features/auth/login_notifier.dart @@ -0,0 +1,83 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/matrix_client.dart'; + +enum LoginStatus { idle, loading, error } + +class LoginState { + final LoginStatus status; + final String? errorMessage; + + const LoginState({this.status = LoginStatus.idle, this.errorMessage}); + + LoginState copyWith({LoginStatus? status, String? errorMessage}) => + LoginState( + status: status ?? this.status, + errorMessage: errorMessage, + ); +} + +class LoginNotifier extends StateNotifier { + final Ref _ref; + + LoginNotifier(this._ref) : super(const LoginState()); + + Future login({ + required String userId, + required String password, + String? deviceName, + }) async { + state = state.copyWith(status: LoginStatus.loading, errorMessage: null); + + final clientAsync = _ref.read(matrixClientProvider); + final client = clientAsync.valueOrNull; + if (client == null) { + state = state.copyWith( + status: LoginStatus.error, + errorMessage: 'Client nicht initialisiert', + ); + return; + } + + try { + // Auto-discover homeserver from MXID + if (userId.isValidMatrixId) { + final domain = Uri.https(userId.domain!, ''); + try { + await client.checkHomeserver(domain); + } catch (_) { + client.homeserver = domain; + } + } + + await client.login( + LoginType.mLoginPassword, + identifier: AuthenticationUserIdentifier(user: userId), + password: password, + initialDeviceDisplayName: deviceName ?? 'Pyramid', + ); + + state = state.copyWith(status: LoginStatus.idle); + } on MatrixException catch (e) { + state = state.copyWith( + status: LoginStatus.error, + errorMessage: e.errorMessage, + ); + } catch (e) { + state = state.copyWith( + status: LoginStatus.error, + errorMessage: e.toString(), + ); + } + } +} + +final loginNotifierProvider = + StateNotifierProvider((ref) { + return LoginNotifier(ref); +}); + +final isLoggedInProvider = Provider((ref) { + final client = ref.watch(matrixClientProvider).valueOrNull; + return client?.isLogged() ?? false; +}); diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart new file mode 100644 index 0000000..98a768d --- /dev/null +++ b/lib/features/auth/login_page.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:pyramid/features/auth/login_notifier.dart'; + +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _userIdCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _userIdCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + await ref.read(loginNotifierProvider.notifier).login( + userId: _userIdCtrl.text.trim(), + password: _passwordCtrl.text, + ); + + final state = ref.read(loginNotifierProvider); + if (state.status == LoginStatus.idle && mounted) { + context.go('/rooms'); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(loginNotifierProvider); + final loading = state.status == LoginStatus.loading; + + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _PyramidLogo(), + const SizedBox(height: 32), + TextFormField( + controller: _userIdCtrl, + decoration: const InputDecoration( + labelText: 'Matrix ID', + hintText: '@name:server.de', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.username], + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Matrix ID eingeben'; + } + if (!v.trim().startsWith('@') || + !v.trim().contains(':')) { + return 'Format: @name:server.de'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordCtrl, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Passwort', + prefixIcon: const Icon(Icons.lock_outline), + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), + ), + ), + autofillHints: const [AutofillHints.password], + validator: (v) => + (v == null || v.isEmpty) ? 'Passwort eingeben' : null, + onFieldSubmitted: (_) => _submit(), + ), + if (state.errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + state.errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: loading ? null : _submit, + child: loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Anmelden'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _PyramidLogo extends StatelessWidget { + const _PyramidLogo(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon( + Icons.change_history_rounded, + size: 72, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + Text( + 'Pyramid', + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3b2d3cc..bb6b432 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pyramid/core/app.dart'; +import 'package:pyramid/core/matrix_client.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - runApp(const ProviderScope(child: PyramidApp())); + runApp(const ProviderScope(child: PyramidBootstrap())); +} + +class PyramidBootstrap extends ConsumerWidget { + const PyramidBootstrap({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(matrixClientProvider); + + return clientAsync.when( + loading: () => const MaterialApp( + home: Scaffold( + body: Center(child: CircularProgressIndicator()), + ), + ), + error: (e, _) => MaterialApp( + home: Scaffold( + body: Center(child: Text('Fehler: $e')), + ), + ), + data: (_) => const PyramidApp(), + ); + } }