diff --git a/lib/core/router.dart b/lib/core/router.dart index 6f0ff4c..7f95ef1 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:pyramid/features/auth/login_notifier.dart'; import 'package:pyramid/features/auth/login_page.dart'; +import 'package:pyramid/features/auth/server_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'; @@ -9,16 +10,29 @@ import 'package:pyramid/layout/shell_page.dart'; final routerProvider = Provider((ref) { final isLoggedIn = ref.watch(isLoggedInProvider); + final homeserverAsync = ref.watch(homeserverProvider); + final hasServer = homeserverAsync.valueOrNull != null; return GoRouter( initialLocation: '/rooms', redirect: (context, state) { - final loggingIn = state.matchedLocation == '/login'; - if (!isLoggedIn && !loggingIn) return '/login'; - if (isLoggedIn && loggingIn) return '/rooms'; + final loc = state.matchedLocation; + final onAuth = loc == '/login' || loc == '/server'; + + if (!isLoggedIn) { + if (!hasServer && loc != '/server') return '/server'; + if (hasServer && loc != '/login' && !onAuth) return '/login'; + return null; + } + + if (isLoggedIn && onAuth) return '/rooms'; return null; }, routes: [ + GoRoute( + path: '/server', + builder: (context, state) => const ServerPage(), + ), GoRoute( path: '/login', builder: (context, state) => const LoginPage(), diff --git a/lib/features/auth/login_notifier.dart b/lib/features/auth/login_notifier.dart index aa9d9f1..b79b4a8 100644 --- a/lib/features/auth/login_notifier.dart +++ b/lib/features/auth/login_notifier.dart @@ -1,6 +1,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/matrix_client.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _prefHomeserver = 'homeserver_url'; + +// Gespeicherter Homeserver aus SharedPreferences +final homeserverProvider = FutureProvider((ref) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_prefHomeserver); +}); + +Future saveHomeserver(String url) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefHomeserver, url); +} enum LoginStatus { idle, loading, error } @@ -19,18 +33,17 @@ class LoginState { class LoginNotifier extends StateNotifier { final Ref _ref; - LoginNotifier(this._ref) : super(const LoginState()); Future login({ - required String userId, + required String username, required String password, + required String homeserver, String? deviceName, }) async { state = state.copyWith(status: LoginStatus.loading, errorMessage: null); - final clientAsync = _ref.read(matrixClientProvider); - final client = clientAsync.valueOrNull; + final client = _ref.read(matrixClientProvider).valueOrNull; if (client == null) { state = state.copyWith( status: LoginStatus.error, @@ -39,20 +52,21 @@ class LoginNotifier extends StateNotifier { return; } + if (client.isLogged()) { + state = state.copyWith(status: LoginStatus.idle); + 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; - } - } + final serverUri = Uri.https( + homeserver.replaceAll(RegExp(r'^https?://'), ''), + '', + ); + await client.checkHomeserver(serverUri); await client.login( LoginType.mLoginPassword, - identifier: AuthenticationUserIdentifier(user: userId), + identifier: AuthenticationUserIdentifier(user: username), password: password, initialDeviceDisplayName: deviceName ?? 'Pyramid', ); diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index 98a768d..0554e01 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -12,22 +12,26 @@ class LoginPage extends ConsumerStatefulWidget { class _LoginPageState extends ConsumerState { final _formKey = GlobalKey(); - final _userIdCtrl = TextEditingController(); - final _passwordCtrl = TextEditingController(); - bool _obscurePassword = true; + final _userCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscure = true; @override void dispose() { - _userIdCtrl.dispose(); - _passwordCtrl.dispose(); + _userCtrl.dispose(); + _passCtrl.dispose(); super.dispose(); } Future _submit() async { if (!_formKey.currentState!.validate()) return; + + final homeserver = ref.read(homeserverProvider).valueOrNull ?? 'matrix.org'; + await ref.read(loginNotifierProvider.notifier).login( - userId: _userIdCtrl.text.trim(), - password: _passwordCtrl.text, + username: _userCtrl.text.trim(), + password: _passCtrl.text, + homeserver: homeserver, ); final state = ref.read(loginNotifierProvider); @@ -38,8 +42,10 @@ class _LoginPageState extends ConsumerState { @override Widget build(BuildContext context) { - final state = ref.watch(loginNotifierProvider); - final loading = state.status == LoginStatus.loading; + final theme = Theme.of(context); + final loginState = ref.watch(loginNotifierProvider); + final loading = loginState.status == LoginStatus.loading; + final homeserver = ref.watch(homeserverProvider).valueOrNull ?? 'matrix.org'; return Scaffold( body: Center( @@ -53,43 +59,73 @@ class _LoginPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _PyramidLogo(), + Icon( + Icons.change_history_rounded, + size: 72, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 8), + Text( + 'Pyramid', + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + // Server-Anzeige mit Wechsel-Link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.dns_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + homeserver, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => context.go('/server'), + child: Text( + 'ändern', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), const SizedBox(height: 32), TextFormField( - controller: _userIdCtrl, + controller: _userCtrl, decoration: const InputDecoration( - labelText: 'Matrix ID', - hintText: '@name:server.de', + labelText: 'Nutzername', + hintText: 'dein_name', 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; - }, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Nutzername eingeben' + : null, ), const SizedBox(height: 16), TextFormField( - controller: _passwordCtrl, - obscureText: _obscurePassword, + controller: _passCtrl, + obscureText: _obscure, decoration: InputDecoration( labelText: 'Passwort', prefixIcon: const Icon(Icons.lock_outline), border: const OutlineInputBorder(), suffixIcon: IconButton( - icon: Icon(_obscurePassword + icon: Icon(_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined), - onPressed: () => setState( - () => _obscurePassword = !_obscurePassword), + onPressed: () => setState(() => _obscure = !_obscure), ), ), autofillHints: const [AutofillHints.password], @@ -97,12 +133,11 @@ class _LoginPageState extends ConsumerState { (v == null || v.isEmpty) ? 'Passwort eingeben' : null, onFieldSubmitted: (_) => _submit(), ), - if (state.errorMessage != null) ...[ + if (loginState.errorMessage != null) ...[ const SizedBox(height: 12), Text( - state.errorMessage!, - style: TextStyle( - color: Theme.of(context).colorScheme.error), + loginState.errorMessage!, + style: TextStyle(color: theme.colorScheme.error), textAlign: TextAlign.center, ), ], @@ -126,28 +161,3 @@ class _LoginPageState extends ConsumerState { ); } } - -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/features/auth/server_page.dart b/lib/features/auth/server_page.dart new file mode 100644 index 0000000..684647a --- /dev/null +++ b/lib/features/auth/server_page.dart @@ -0,0 +1,128 @@ +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 ServerPage extends ConsumerStatefulWidget { + const ServerPage({super.key}); + + @override + ConsumerState createState() => _ServerPageState(); +} + +class _ServerPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _ctrl = TextEditingController(text: 'matrix.org'); + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + ref.read(homeserverProvider.future).then((saved) { + if (saved != null && mounted) { + _ctrl.text = saved.replaceAll(RegExp(r'^https?://'), ''); + } + }); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _continue() async { + if (!_formKey.currentState!.validate()) return; + setState(() { _loading = true; _error = null; }); + + final input = _ctrl.text.trim().replaceAll(RegExp(r'^https?://'), ''); + try { + await saveHomeserver(input); + if (mounted) context.go('/login'); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + 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: [ + Icon( + Icons.change_history_rounded, + size: 72, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 8), + Text( + 'Pyramid', + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 32), + Text( + 'Server', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + TextFormField( + controller: _ctrl, + decoration: const InputDecoration( + hintText: 'matrix.org', + prefixText: 'https://', + prefixIcon: Icon(Icons.dns_outlined), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Server-Adresse eingeben'; + } + return null; + }, + onFieldSubmitted: (_) => _continue(), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _loading ? null : _continue, + child: _loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Weiter'), + ), + ], + ), + ), + ), + ), + ), + ); + } +}