diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 5f48424..4961bb3 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -1,4 +1,4 @@ -/// Signed-in account details, sync status, and sign-out. +/// Signed-in account details, sync status, bank link, and sign-out. library; import 'package:flutter/material.dart'; @@ -8,6 +8,7 @@ import '../providers/auth_provider.dart'; import '../providers/bank_provider.dart'; import '../providers/sync_provider.dart'; import '../providers/tx_meta_provider.dart'; +import '../utils/connect_bank.dart'; import '../utils/constants.dart'; import '../widgets/responsive_body.dart'; @@ -89,12 +90,38 @@ class AccountScreen extends StatelessWidget { ], ), ), + const SizedBox(height: 16), + Card( + child: ListTile( + leading: Icon( + bank.isLinked ? Icons.link : Icons.link_off_outlined, + ), + title: const Text('Bank link'), + subtitle: Text( + bank.isLinked + ? 'Linked · ${bank.status.status}' + : bank.hasData + ? 'Not linked · ${bank.transactions.length} synced txs on device' + : 'Not linked · connect to fetch live balance and feed', + ), + trailing: bank.isLinked + ? null + : TextButton( + onPressed: bank.isConnecting + ? null + : () => runConnectBank(context, bank), + child: Text( + bank.isConnecting ? 'Opening…' : 'Connect', + ), + ), + ), + ), const SizedBox(height: 24), OutlinedButton.icon( onPressed: () async { await auth.logout(); if (!context.mounted) return; - Navigator.of(context).pop(); + Navigator.of(context).popUntil((route) => route.isFirst); }, icon: const Icon(Icons.logout), label: const Text('Sign out'), diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 16e4233..faa02b3 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -11,7 +11,11 @@ import '../widgets/app_text_field.dart'; import '../widgets/responsive_body.dart'; class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); + const LoginScreen({super.key, this.asRoot = false}); + + /// When true (cold start / auth gate), stay put on success so [AuthGate] + /// rebuilds into the shell. When false, pop with `true` for modal sign-in. + final bool asRoot; @override State createState() => _LoginScreenState(); @@ -43,7 +47,9 @@ class _LoginScreenState extends State { password: _passwordController.text, ); if (!mounted) return; - Navigator.of(context).pop(true); + if (!widget.asRoot) { + Navigator.of(context).pop(true); + } } on ClientException catch (error) { if (!mounted) return; final message = error.response['message'] as String? ?? @@ -66,19 +72,25 @@ class _LoginScreenState extends State { final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text('Sign in')), + appBar: AppBar( + title: const Text('Sign in'), + automaticallyImplyLeading: !widget.asRoot, + ), body: ResponsiveBody( child: ListView( padding: const EdgeInsets.fromLTRB(16, 24, 16, 32), children: [ Text( - 'Sync account', + widget.asRoot ? 'Welcome to $kAppName' : 'Sync account', style: theme.textTheme.headlineSmall, ), const SizedBox(height: 8), Text( - 'Sign in to link this device with PocketBase. Labels, notes, ' - 'and bank transactions sync across your devices.', + widget.asRoot + ? 'Sign in to sync labels, notes, and transactions across ' + 'your devices. You can connect your bank afterward.' + : 'Sign in to link this device with PocketBase. Labels, notes, ' + 'and bank transactions sync across your devices.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bf4b7fa..86cad80 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -177,6 +177,34 @@ class SettingsScreen extends StatelessWidget { ), ), const SizedBox(height: 28), + Text('Bank', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: Icon( + bank.isLinked ? Icons.link : Icons.link_off_outlined, + ), + title: const Text('Bank link'), + subtitle: Text( + bank.isLinked + ? 'Linked · ${bank.status.status}' + : bank.hasData + ? 'Not linked · ${bank.transactions.length} synced txs on device' + : 'Not linked · connect to fetch live balance and feed', + ), + trailing: bank.isLinked + ? null + : TextButton( + onPressed: bank.isConnecting + ? null + : () => runConnectBank(context, bank), + child: Text( + bank.isConnecting ? 'Opening…' : 'Connect', + ), + ), + ), + ), + const SizedBox(height: 28), Text('Backend', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( @@ -188,30 +216,6 @@ class SettingsScreen extends StatelessWidget { subtitle: Text(bank.apiBaseUrl), ), const Divider(height: 1), - ListTile( - leading: Icon( - bank.isLinked - ? Icons.link - : Icons.link_off_outlined, - ), - title: const Text('Bank link'), - subtitle: Text( - bank.isLinked - ? 'Linked · ${bank.status.status}' - : 'Not linked', - ), - trailing: bank.isLinked - ? null - : TextButton( - onPressed: bank.isConnecting - ? null - : () => runConnectBank(context, bank), - child: Text( - bank.isConnecting ? 'Opening…' : 'Connect', - ), - ), - ), - const Divider(height: 1), ListTile( leading: const Icon(Icons.open_in_browser_outlined), title: const Text('Enable Banking redirect'), diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 80525e9..f538b92 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,12 +1,12 @@ -/// First route shown after launch. Hands off to [MainShell] after a short delay. +/// First route shown after launch. Hands off to [AuthGate] after a short delay. library; import 'package:flutter/material.dart'; import '../utils/constants.dart'; -import 'main_shell.dart'; +import 'auth_gate.dart'; -/// Branded splash with a fade-in logo. Replaced by the bottom-nav shell. +/// Branded splash with a fade-in logo. Replaced by the auth gate. class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -32,17 +32,17 @@ class _SplashScreenState extends State CurvedAnimation(parent: _controller, curve: Curves.easeOutBack), ); _controller.forward(); - _goToShell(); + _goToAuthGate(); } - Future _goToShell() async { + Future _goToAuthGate() async { await Future.delayed(kSplashDuration); if (!mounted) return; Navigator.of(context).pushReplacement( PageRouteBuilder( pageBuilder: (_, animation, _) => FadeTransition( opacity: animation, - child: const MainShell(), + child: const AuthGate(), ), transitionDuration: const Duration(milliseconds: 400), ), diff --git a/lib/utils/navigation.dart b/lib/utils/navigation.dart index 67c1434..fe49edc 100644 --- a/lib/utils/navigation.dart +++ b/lib/utils/navigation.dart @@ -46,119 +46,92 @@ class _SyncAwareAccountMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: context.read(), - builder: (context, _) { - final sync = context.read(); - final showRing = sync.showProgressRing; - final progress = sync.syncProgress; - final success = sync.progressSuccess; - final syncActive = sync.isSyncing || showRing; - final ringColor = success - ? const Color(0xFF16A34A) - : Theme.of(context).colorScheme.primary; + final colors = Theme.of(context).colorScheme; - return PopupMenuButton<_AccountMenuAction>( - tooltip: sync.isSyncing - ? 'Syncing… ${(progress * 100).round()}%' - : auth.displayName, - offset: const Offset(0, 48), - onSelected: (action) async { - switch (action) { - case _AccountMenuAction.account: - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const AccountScreen(), - ), - ); - case _AccountMenuAction.logout: - await auth.logout(); - } + // MenuAnchor (not PopupMenuButton): no modal black barrier, and the menu + // stays anchored under this trailing control instead of jumping left. + return MenuAnchor( + consumeOutsideTap: true, + alignmentOffset: const Offset(0, 4), + style: MenuStyle( + alignment: AlignmentDirectional.bottomEnd, + backgroundColor: WidgetStatePropertyAll(colors.surfaceContainerHigh), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + elevation: const WidgetStatePropertyAll(6), + shadowColor: const WidgetStatePropertyAll(Colors.black38), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(vertical: 8), + ), + ), + builder: (context, controller, child) { + return ListenableBuilder( + listenable: context.read(), + builder: (context, _) { + final sync = context.read(); + final tooltip = sync.isSyncing + ? 'Syncing… ${(sync.syncProgress * 100).round()}%' + : auth.displayName; + + return IconButton( + tooltip: tooltip, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: child ?? const SizedBox.shrink(), + ); }, - itemBuilder: (context) { - final email = auth.email; - final showSyncRow = syncActive; - return [ - if (email != null) - PopupMenuItem<_AccountMenuAction>( - enabled: false, - child: Text( - email, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - if (showSyncRow) - const PopupMenuItem<_AccountMenuAction>( - enabled: false, - child: _SyncProgressMenuTile(), - ), - const PopupMenuItem<_AccountMenuAction>( - value: _AccountMenuAction.account, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.person_outline), - title: Text('Account'), - ), - ), - const PopupMenuItem<_AccountMenuAction>( - value: _AccountMenuAction.logout, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.logout), - title: Text('Sign out'), - ), - ), - ]; - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: SizedBox( - width: 32, - height: 32, - child: Stack( - alignment: Alignment.center, - children: [ - CircleAvatar( - radius: 16, - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, - child: Text( - auth.initials, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - if (showRing) - SizedBox( - width: 32, - height: 32, - child: CircularProgressIndicator( - value: - progress <= 0 ? null : progress.clamp(0.02, 1.0), - strokeWidth: 2.5, - color: ringColor, - backgroundColor: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.45), - ), - ), - ], - ), - ), - ), ); }, + menuChildren: [ + if (auth.email case final email?) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + email, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + const _SyncProgressMenuEntry(), + MenuItemButton( + leadingIcon: const Icon(Icons.person_outline), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AccountScreen(), + ), + ); + }, + child: const Text('Account'), + ), + MenuItemButton( + leadingIcon: const Icon(Icons.logout), + onPressed: () async { + await auth.logout(); + if (!context.mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); + }, + child: const Text('Sign out'), + ), + ], + child: _AccountAvatar(auth: auth), ); } } -/// Live sync status row inside the account popup (watches [SyncController]). -class _SyncProgressMenuTile extends StatelessWidget { - const _SyncProgressMenuTile(); +/// Avatar + optional sync ring; watches sync without rebuilding the menu shell. +class _AccountAvatar extends StatelessWidget { + const _AccountAvatar({required this.auth}); + + final AuthController auth; @override Widget build(BuildContext context) { @@ -166,50 +139,118 @@ class _SyncProgressMenuTile extends StatelessWidget { listenable: context.read(), builder: (context, _) { final sync = context.read(); - final theme = Theme.of(context); - final colors = theme.colorScheme; - final pct = (sync.syncProgress * 100).round().clamp(0, 100); - final detail = sync.statusDetail.trim(); + final showRing = sync.showProgressRing; + final progress = sync.syncProgress; + final success = sync.progressSuccess; + final ringColor = success + ? const Color(0xFF16A34A) + : Theme.of(context).colorScheme.primary; - if (sync.progressSuccess) { - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.check_circle_outline, color: colors.primary), - title: Text( - 'Sync complete', - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurface, + return SizedBox( + width: 32, + height: 32, + child: Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + child: Text( + auth.initials, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), ), - ), - ); - } - - return ListTile( - contentPadding: EdgeInsets.zero, - leading: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: sync.syncProgress <= 0 - ? null - : sync.syncProgress.clamp(0.02, 1.0), - strokeWidth: 2.5, - ), + if (showRing) + SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + value: progress <= 0 ? null : progress.clamp(0.02, 1.0), + strokeWidth: 2.5, + color: ringColor, + backgroundColor: Theme.of(context) + .colorScheme + .outlineVariant + .withValues(alpha: 0.45), + ), + ), + ], ), - title: Text( - detail.isEmpty ? 'Sync in progress ($pct%)' : detail, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurface, - ), - ), - subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'), ); }, ); } } -enum _AccountMenuAction { account, logout } +/// Sync row shown only while a sync is active / finishing. +class _SyncProgressMenuEntry extends StatelessWidget { + const _SyncProgressMenuEntry(); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: context.read(), + builder: (context, _) { + final sync = context.read(); + if (!sync.isSyncing && !sync.showProgressRing) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final colors = theme.colorScheme; + final pct = (sync.syncProgress * 100).round().clamp(0, 100); + final detail = sync.statusDetail.trim(); + + if (sync.progressSuccess) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + Icons.check_circle_outline, + color: colors.primary, + size: 20, + ), + const SizedBox(width: 12), + Text('Sync complete', style: theme.textTheme.bodyMedium), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + value: sync.syncProgress <= 0 + ? null + : sync.syncProgress.clamp(0.02, 1.0), + strokeWidth: 2.5, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + detail.isEmpty ? 'Sync in progress ($pct%)' : detail, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + }, + ); + } +} List bankAppBarActions({ required BuildContext context, diff --git a/test/widget_test.dart b/test/widget_test.dart index 820664f..7a866b9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -24,7 +24,8 @@ void main() { await tester.pumpWidget(MyApp(storage: storage, auth: auth)); } - testWidgets('splash shows the app name then opens Home', (tester) async { + testWidgets('splash shows the app name then opens Sign in when logged out', + (tester) async { await pumpApp(tester); expect(find.text(kAppName), findsWidgets); @@ -33,9 +34,9 @@ void main() { await tester.pump(const Duration(milliseconds: 450)); await tester.pump(const Duration(milliseconds: 350)); - expect(find.text('Home'), findsWidgets); - expect(find.text('Overview'), findsOneWidget); - expect(find.byTooltip('Settings'), findsOneWidget); + expect(find.text('Sign in'), findsWidgets); + expect(find.textContaining('Welcome to'), findsOneWidget); + expect(find.text('Home'), findsNothing); }); test('ConnectStart.fromJson maps authUrl and state', () { @@ -116,6 +117,7 @@ void main() { test('look-ahead applies monthly recurring amounts', () { final items = [ const RecurringItem( + templateId: 'salary', description: 'Salary', amount: 2000, currency: 'EUR', @@ -123,6 +125,7 @@ void main() { labelIds: ['salary'], ), const RecurringItem( + templateId: 'rent', description: 'Rent', amount: -900, currency: 'EUR',