fix login

This commit is contained in:
davmar
2026-09-10 10:51:45 +02:00
parent 79e4417edb
commit 56cc0fc94c
6 changed files with 269 additions and 182 deletions
+29 -2
View File
@@ -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'),
+16 -4
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
@@ -43,7 +47,9 @@ class _LoginScreenState extends State<LoginScreen> {
password: _passwordController.text,
);
if (!mounted) return;
if (!widget.asRoot) {
Navigator.of(context).pop(true);
}
} on ClientException catch (error) {
if (!mounted) return;
final message = error.response['message'] as String? ??
@@ -66,18 +72,24 @@ class _LoginScreenState extends State<LoginScreen> {
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, '
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,
+18 -14
View File
@@ -177,28 +177,20 @@ class SettingsScreen extends StatelessWidget {
),
),
const SizedBox(height: 28),
Text('Backend', style: theme.textTheme.titleMedium),
Text('Bank', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.dns_outlined),
title: const Text('API base URL'),
subtitle: Text(bank.apiBaseUrl),
),
const Divider(height: 1),
ListTile(
child: ListTile(
leading: Icon(
bank.isLinked
? Icons.link
: Icons.link_off_outlined,
bank.isLinked ? Icons.link : Icons.link_off_outlined,
),
title: const Text('Bank link'),
subtitle: Text(
bank.isLinked
? 'Linked · ${bank.status.status}'
: 'Not linked',
: bank.hasData
? 'Not linked · ${bank.transactions.length} synced txs on device'
: 'Not linked · connect to fetch live balance and feed',
),
trailing: bank.isLinked
? null
@@ -211,6 +203,18 @@ class SettingsScreen extends StatelessWidget {
),
),
),
),
const SizedBox(height: 28),
Text('Backend', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.dns_outlined),
title: const Text('API base URL'),
subtitle: Text(bank.apiBaseUrl),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.open_in_browser_outlined),
+6 -6
View File
@@ -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<SplashScreen>
CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
);
_controller.forward();
_goToShell();
_goToAuthGate();
}
Future<void> _goToShell() async {
Future<void> _goToAuthGate() async {
await Future<void>.delayed(kSplashDuration);
if (!mounted) return;
Navigator.of(context).pushReplacement(
PageRouteBuilder<void>(
pageBuilder: (_, animation, _) => FadeTransition(
opacity: animation,
child: const MainShell(),
child: const AuthGate(),
),
transitionDuration: const Duration(milliseconds: 400),
),
+123 -82
View File
@@ -44,6 +44,95 @@ class _SyncAwareAccountMenu extends StatelessWidget {
final AuthController auth;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
// 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<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
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(),
);
},
);
},
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<void>(
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),
);
}
}
/// 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) {
return ListenableBuilder(
@@ -53,73 +142,18 @@ class _SyncAwareAccountMenu extends StatelessWidget {
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;
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<void>(
builder: (_) => const AccountScreen(),
),
);
case _AccountMenuAction.logout:
await auth.logout();
}
},
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(
return SizedBox(
width: 32,
height: 32,
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 16,
radius: 14,
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Text(
@@ -136,8 +170,7 @@ class _SyncAwareAccountMenu extends StatelessWidget {
width: 32,
height: 32,
child: CircularProgressIndicator(
value:
progress <= 0 ? null : progress.clamp(0.02, 1.0),
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
strokeWidth: 2.5,
color: ringColor,
backgroundColor: Theme.of(context)
@@ -148,17 +181,15 @@ class _SyncAwareAccountMenu extends StatelessWidget {
),
],
),
),
),
);
},
);
}
}
/// Live sync status row inside the account popup (watches [SyncController]).
class _SyncProgressMenuTile extends StatelessWidget {
const _SyncProgressMenuTile();
/// Sync row shown only while a sync is active / finishing.
class _SyncProgressMenuEntry extends StatelessWidget {
const _SyncProgressMenuEntry();
@override
Widget build(BuildContext context) {
@@ -166,29 +197,39 @@ class _SyncProgressMenuTile extends StatelessWidget {
listenable: context.read<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
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 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 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 ListTile(
contentPadding: EdgeInsets.zero,
leading: SizedBox(
width: 24,
height: 24,
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
@@ -196,21 +237,21 @@ class _SyncProgressMenuTile extends StatelessWidget {
strokeWidth: 2.5,
),
),
title: Text(
const SizedBox(width: 12),
Expanded(
child: Text(
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
style: theme.textTheme.bodyMedium,
),
),
subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'),
],
),
);
},
);
}
}
enum _AccountMenuAction { account, logout }
List<Widget> bankAppBarActions({
required BuildContext context,
VoidCallback? onRefresh,
+7 -4
View File
@@ -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',