fix login
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
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<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, '
|
||||
'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,
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
+181
-140
@@ -46,119 +46,92 @@ class _SyncAwareAccountMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: context.read<SyncController>(),
|
||||
builder: (context, _) {
|
||||
final sync = context.read<SyncController>();
|
||||
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<void>(
|
||||
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<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(),
|
||||
);
|
||||
},
|
||||
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<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SyncController>(),
|
||||
builder: (context, _) {
|
||||
final sync = context.read<SyncController>();
|
||||
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<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 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<Widget> bankAppBarActions({
|
||||
required BuildContext context,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user