Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec89ac41d | ||
|
|
68623b38ff | ||
|
|
56cc0fc94c | ||
|
|
79e4417edb |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Generic Flutter Android starter built with Flutter **3.47** (Dart **3.13**), Material 3, null safety, and [Provider](https://pub.dev/packages/provider) for state management.
|
Generic Flutter Android starter built with Flutter **3.47** (Dart **3.13**), Material 3, null safety, and [Provider](https://pub.dev/packages/provider) for state management.
|
||||||
|
|
||||||
Package name: `com.example.myapp`
|
Package name: `com.ingtrak.app`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.myapp"
|
namespace = "com.ingtrak.app"
|
||||||
// file_picker / flutter_plugin_android_lifecycle require API 36+.
|
// file_picker / flutter_plugin_android_lifecycle require API 36+.
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
@@ -17,7 +17,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.example.myapp"
|
applicationId = "com.ingtrak.app"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.myapp
|
package com.ingtrak.app
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
@@ -36,6 +36,12 @@ class BankController extends ChangeNotifier {
|
|||||||
final IngtrakApi _api;
|
final IngtrakApi _api;
|
||||||
final StorageService? _storage;
|
final StorageService? _storage;
|
||||||
|
|
||||||
|
/// Fired after a successful live balance + transactions fetch.
|
||||||
|
///
|
||||||
|
/// Used by [SyncController] to reconcile with PocketBase so a bank refresh
|
||||||
|
/// cannot leave the device behind the cloud (or wipe cloud-only history).
|
||||||
|
VoidCallback? onAfterSuccessfulLoad;
|
||||||
|
|
||||||
ConnectStatus _status = ConnectStatus.disconnected;
|
ConnectStatus _status = ConnectStatus.disconnected;
|
||||||
BankBalance? _balance;
|
BankBalance? _balance;
|
||||||
List<BankTransaction> _remoteTransactions = const [];
|
List<BankTransaction> _remoteTransactions = const [];
|
||||||
@@ -193,6 +199,7 @@ class BankController extends ChangeNotifier {
|
|||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
var loadedLive = false;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
_status = await _api.fetchStatus();
|
_status = await _api.fetchStatus();
|
||||||
@@ -209,7 +216,7 @@ class BankController extends ChangeNotifier {
|
|||||||
// have a local snapshot. Never wipe local txs because status said
|
// have a local snapshot. Never wipe local txs because status said
|
||||||
// "unlinked" — only a 409 from balance/transactions clears them.
|
// "unlinked" — only a 409 from balance/transactions clears them.
|
||||||
if (_status.linked || hasData) {
|
if (_status.linked || hasData) {
|
||||||
await _loadLinkedData();
|
loadedLive = await _loadLinkedData();
|
||||||
await _persistCache();
|
await _persistCache();
|
||||||
}
|
}
|
||||||
} on IngtrakApiException catch (error, stackTrace) {
|
} on IngtrakApiException catch (error, stackTrace) {
|
||||||
@@ -230,16 +237,24 @@ class BankController extends ChangeNotifier {
|
|||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadedLive) {
|
||||||
|
onAfterSuccessfulLoad?.call();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLinkedData() async {
|
/// Returns true when balance + transactions were fetched successfully.
|
||||||
|
Future<bool> _loadLinkedData() async {
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
_api.fetchBalance(),
|
_api.fetchBalance(),
|
||||||
_api.fetchTransactions(),
|
_api.fetchTransactions(),
|
||||||
]);
|
]);
|
||||||
_balance = results[0] as BankBalance;
|
_balance = results[0] as BankBalance;
|
||||||
_remoteTransactions = results[1] as List<BankTransaction>;
|
final apiTxs = results[1] as List<BankTransaction>;
|
||||||
|
// Bank API is ~89 days. Prefer API rows for overlaps, but keep any
|
||||||
|
// cloud/CSV history that the API window does not include.
|
||||||
|
_remoteTransactions = mergeTransactions(apiTxs, _remoteTransactions);
|
||||||
if (!_status.linked) {
|
if (!_status.linked) {
|
||||||
_status = ConnectStatus(
|
_status = ConnectStatus(
|
||||||
status: 'linked',
|
status: 'linked',
|
||||||
@@ -257,6 +272,7 @@ class BankController extends ChangeNotifier {
|
|||||||
_invalidateMerged();
|
_invalidateMerged();
|
||||||
await _persistImported();
|
await _persistImported();
|
||||||
_error = null;
|
_error = null;
|
||||||
|
return true;
|
||||||
} on IngtrakApiException catch (error) {
|
} on IngtrakApiException catch (error) {
|
||||||
if (error.isNotLinked) {
|
if (error.isNotLinked) {
|
||||||
// Confirmed unlinked by data endpoints — clear remote only.
|
// Confirmed unlinked by data endpoints — clear remote only.
|
||||||
@@ -272,6 +288,7 @@ class BankController extends ChangeNotifier {
|
|||||||
// Keep previous balance/txs if refresh of data fails.
|
// Keep previous balance/txs if refresh of data fails.
|
||||||
_error = 'Using saved data · ${error.message}';
|
_error = 'Using saved data · ${error.message}';
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,11 +366,19 @@ class BankController extends ChangeNotifier {
|
|||||||
state: state ?? _pendingState,
|
state: state ?? _pendingState,
|
||||||
);
|
);
|
||||||
_pendingState = null;
|
_pendingState = null;
|
||||||
|
var loadedLive = false;
|
||||||
if (_status.linked) {
|
if (_status.linked) {
|
||||||
await _loadLinkedData();
|
loadedLive = await _loadLinkedData();
|
||||||
await _persistCache();
|
await _persistCache();
|
||||||
}
|
}
|
||||||
return _status.linked;
|
final linked = _status.linked;
|
||||||
|
// Notify before cloud reconcile so UI can leave the connecting state.
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
if (loadedLive) {
|
||||||
|
onAfterSuccessfulLoad?.call();
|
||||||
|
}
|
||||||
|
return linked;
|
||||||
} on IngtrakApiException catch (error, stackTrace) {
|
} on IngtrakApiException catch (error, stackTrace) {
|
||||||
_error = error.message;
|
_error = error.message;
|
||||||
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
|
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
|
||||||
@@ -363,13 +388,16 @@ class BankController extends ChangeNotifier {
|
|||||||
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
|
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (_isLoading) {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
onAfterSuccessfulLoad = null;
|
||||||
_api.close();
|
_api.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
_wasLoggedIn = auth.isLoggedIn;
|
_wasLoggedIn = auth.isLoggedIn;
|
||||||
_meta.onPersisted = _schedulePush;
|
_meta.onPersisted = _schedulePush;
|
||||||
_auth.addListener(_onAuthChanged);
|
_auth.addListener(_onAuthChanged);
|
||||||
|
_bank.onAfterSuccessfulLoad = _onBankDataLoaded;
|
||||||
if (_auth.isLoggedIn) {
|
if (_auth.isLoggedIn) {
|
||||||
unawaited(syncNow());
|
unawaited(syncNow());
|
||||||
}
|
}
|
||||||
@@ -188,6 +189,33 @@ class SyncController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bank pull-to-refresh / reconnect finished — reconcile with PocketBase.
|
||||||
|
///
|
||||||
|
/// Always runs a full sync when logged in so (1) new API txs are pushed and
|
||||||
|
/// (2) any cloud-only history the bank window lacks is pulled back.
|
||||||
|
void _onBankDataLoaded() {
|
||||||
|
if (!_auth.isLoggedIn) return;
|
||||||
|
unawaited(syncNow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the server has more bank txs than this device, sync now.
|
||||||
|
Future<void> syncIfBehindServer() async {
|
||||||
|
if (!_auth.isLoggedIn || _syncing) return;
|
||||||
|
try {
|
||||||
|
final remoteCount =
|
||||||
|
await PbSyncService(_auth.client).countTransactions();
|
||||||
|
if (remoteCount > _bank.transactions.length) {
|
||||||
|
debugPrint(
|
||||||
|
'SyncController: behind server '
|
||||||
|
'(local=${_bank.transactions.length} remote=$remoteCount) — syncing',
|
||||||
|
);
|
||||||
|
await syncNow();
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint('SyncController.syncIfBehindServer failed: $error\n$stackTrace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _schedulePush() {
|
void _schedulePush() {
|
||||||
if (!_auth.isLoggedIn) return;
|
if (!_auth.isLoggedIn) return;
|
||||||
_pushDebounce?.cancel();
|
_pushDebounce?.cancel();
|
||||||
@@ -329,6 +357,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
_progressHold?.cancel();
|
_progressHold?.cancel();
|
||||||
_uiNotifyThrottle?.cancel();
|
_uiNotifyThrottle?.cancel();
|
||||||
_auth.removeListener(_onAuthChanged);
|
_auth.removeListener(_onAuthChanged);
|
||||||
|
_bank.onAfterSuccessfulLoad = null;
|
||||||
_meta.onPersisted = null;
|
_meta.onPersisted = null;
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -8,6 +8,7 @@ import '../providers/auth_provider.dart';
|
|||||||
import '../providers/bank_provider.dart';
|
import '../providers/bank_provider.dart';
|
||||||
import '../providers/sync_provider.dart';
|
import '../providers/sync_provider.dart';
|
||||||
import '../providers/tx_meta_provider.dart';
|
import '../providers/tx_meta_provider.dart';
|
||||||
|
import '../utils/connect_bank.dart';
|
||||||
import '../utils/constants.dart';
|
import '../utils/constants.dart';
|
||||||
import '../widgets/responsive_body.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),
|
const SizedBox(height: 24),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
label: const Text('Sign out'),
|
label: const Text('Sign out'),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// Routes to [LoginScreen] or [MainShell] based on PocketBase auth.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
import 'login_screen.dart';
|
||||||
|
import 'main_shell.dart';
|
||||||
|
|
||||||
|
/// Root after splash: signed-out users see login; signed-in users see the shell.
|
||||||
|
class AuthGate extends StatelessWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = context.watch<AuthController>();
|
||||||
|
if (!auth.isLoggedIn) {
|
||||||
|
return const LoginScreen(asRoot: true);
|
||||||
|
}
|
||||||
|
return const MainShell();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,11 @@ import '../widgets/app_text_field.dart';
|
|||||||
import '../widgets/responsive_body.dart';
|
import '../widgets/responsive_body.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
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
|
@override
|
||||||
State<LoginScreen> createState() => _LoginScreenState();
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
@@ -43,7 +47,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (!widget.asRoot) {
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
} on ClientException catch (error) {
|
} on ClientException catch (error) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final message = error.response['message'] as String? ??
|
final message = error.response['message'] as String? ??
|
||||||
@@ -66,18 +72,24 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sign in')),
|
appBar: AppBar(
|
||||||
|
title: const Text('Sign in'),
|
||||||
|
automaticallyImplyLeading: !widget.asRoot,
|
||||||
|
),
|
||||||
body: ResponsiveBody(
|
body: ResponsiveBody(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 32),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Sync account',
|
widget.asRoot ? 'Welcome to $kAppName' : 'Sync account',
|
||||||
style: theme.textTheme.headlineSmall,
|
style: theme.textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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.',
|
'and bank transactions sync across your devices.',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/sync_provider.dart';
|
||||||
import '../widgets/shell_tab_scope.dart';
|
import '../widgets/shell_tab_scope.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
import 'look_ahead_screen.dart';
|
import 'look_ahead_screen.dart';
|
||||||
@@ -24,6 +26,15 @@ class _MainShellState extends State<MainShell> {
|
|||||||
LookAheadScreen(),
|
LookAheadScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.read<SyncController>().syncIfBehindServer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ShellTabScope(
|
return ShellTabScope(
|
||||||
|
|||||||
@@ -177,28 +177,20 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Text('Backend', style: theme.textTheme.titleMedium),
|
Text('Bank', style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Card(
|
Card(
|
||||||
child: Column(
|
child: ListTile(
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.dns_outlined),
|
|
||||||
title: const Text('API base URL'),
|
|
||||||
subtitle: Text(bank.apiBaseUrl),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
bank.isLinked
|
bank.isLinked ? Icons.link : Icons.link_off_outlined,
|
||||||
? Icons.link
|
|
||||||
: Icons.link_off_outlined,
|
|
||||||
),
|
),
|
||||||
title: const Text('Bank link'),
|
title: const Text('Bank link'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
bank.isLinked
|
bank.isLinked
|
||||||
? 'Linked · ${bank.status.status}'
|
? '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
|
trailing: bank.isLinked
|
||||||
? null
|
? 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),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.open_in_browser_outlined),
|
leading: const Icon(Icons.open_in_browser_outlined),
|
||||||
|
|||||||
@@ -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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../utils/constants.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 {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
@@ -32,17 +32,17 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
|
CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
|
||||||
);
|
);
|
||||||
_controller.forward();
|
_controller.forward();
|
||||||
_goToShell();
|
_goToAuthGate();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _goToShell() async {
|
Future<void> _goToAuthGate() async {
|
||||||
await Future<void>.delayed(kSplashDuration);
|
await Future<void>.delayed(kSplashDuration);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
PageRouteBuilder<void>(
|
PageRouteBuilder<void>(
|
||||||
pageBuilder: (_, animation, _) => FadeTransition(
|
pageBuilder: (_, animation, _) => FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: const MainShell(),
|
child: const AuthGate(),
|
||||||
),
|
),
|
||||||
transitionDuration: const Duration(milliseconds: 400),
|
transitionDuration: const Duration(milliseconds: 400),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -335,6 +335,22 @@ class PbSyncService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cheap remote count for stale detection (1 row, with totals).
|
||||||
|
Future<int> countTransactions() async {
|
||||||
|
final userId = _userId;
|
||||||
|
final filter = 'user="$userId"';
|
||||||
|
final result = await _withTimeout(
|
||||||
|
_pb.collection('transactions').getList(
|
||||||
|
page: 1,
|
||||||
|
perPage: 1,
|
||||||
|
filter: filter,
|
||||||
|
skipTotal: false,
|
||||||
|
),
|
||||||
|
label: 'transactions count',
|
||||||
|
);
|
||||||
|
return result.totalItems;
|
||||||
|
}
|
||||||
|
|
||||||
/// Pulls the full bank transaction list for the signed-in user.
|
/// Pulls the full bank transaction list for the signed-in user.
|
||||||
Future<List<BankTransaction>> pullTransactions({
|
Future<List<BankTransaction>> pullTransactions({
|
||||||
void Function(double progress)? onProgress,
|
void Function(double progress)? onProgress,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'app_config.dart';
|
|||||||
const String kAppName = 'IngTrak';
|
const String kAppName = 'IngTrak';
|
||||||
|
|
||||||
/// Package / Android application id.
|
/// Package / Android application id.
|
||||||
const String kPackageName = 'com.example.myapp';
|
const String kPackageName = 'com.ingtrak.app';
|
||||||
|
|
||||||
/// How long the splash screen stays visible before navigating to the shell.
|
/// How long the splash screen stays visible before navigating to the shell.
|
||||||
const Duration kSplashDuration = Duration(seconds: 2);
|
const Duration kSplashDuration = Duration(seconds: 2);
|
||||||
|
|||||||
+123
-82
@@ -44,6 +44,95 @@ class _SyncAwareAccountMenu extends StatelessWidget {
|
|||||||
|
|
||||||
final AuthController auth;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
@@ -53,73 +142,18 @@ class _SyncAwareAccountMenu extends StatelessWidget {
|
|||||||
final showRing = sync.showProgressRing;
|
final showRing = sync.showProgressRing;
|
||||||
final progress = sync.syncProgress;
|
final progress = sync.syncProgress;
|
||||||
final success = sync.progressSuccess;
|
final success = sync.progressSuccess;
|
||||||
final syncActive = sync.isSyncing || showRing;
|
|
||||||
final ringColor = success
|
final ringColor = success
|
||||||
? const Color(0xFF16A34A)
|
? const Color(0xFF16A34A)
|
||||||
: Theme.of(context).colorScheme.primary;
|
: Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
return PopupMenuButton<_AccountMenuAction>(
|
return SizedBox(
|
||||||
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(
|
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 16,
|
radius: 14,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
Theme.of(context).colorScheme.primaryContainer,
|
Theme.of(context).colorScheme.primaryContainer,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -136,8 +170,7 @@ class _SyncAwareAccountMenu extends StatelessWidget {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value:
|
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
|
||||||
progress <= 0 ? null : progress.clamp(0.02, 1.0),
|
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
color: ringColor,
|
color: ringColor,
|
||||||
backgroundColor: Theme.of(context)
|
backgroundColor: Theme.of(context)
|
||||||
@@ -148,17 +181,15 @@ class _SyncAwareAccountMenu extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live sync status row inside the account popup (watches [SyncController]).
|
/// Sync row shown only while a sync is active / finishing.
|
||||||
class _SyncProgressMenuTile extends StatelessWidget {
|
class _SyncProgressMenuEntry extends StatelessWidget {
|
||||||
const _SyncProgressMenuTile();
|
const _SyncProgressMenuEntry();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -166,29 +197,39 @@ class _SyncProgressMenuTile extends StatelessWidget {
|
|||||||
listenable: context.read<SyncController>(),
|
listenable: context.read<SyncController>(),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final sync = context.read<SyncController>();
|
final sync = context.read<SyncController>();
|
||||||
|
if (!sync.isSyncing && !sync.showProgressRing) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
|
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
|
||||||
final detail = sync.statusDetail.trim();
|
final detail = sync.statusDetail.trim();
|
||||||
|
|
||||||
if (sync.progressSuccess) {
|
if (sync.progressSuccess) {
|
||||||
return ListTile(
|
return Padding(
|
||||||
contentPadding: EdgeInsets.zero,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
leading: Icon(Icons.check_circle_outline, color: colors.primary),
|
child: Row(
|
||||||
title: Text(
|
children: [
|
||||||
'Sync complete',
|
Icon(
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
Icons.check_circle_outline,
|
||||||
color: colors.onSurface,
|
color: colors.primary,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text('Sync complete', style: theme.textTheme.bodyMedium),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListTile(
|
return Padding(
|
||||||
contentPadding: EdgeInsets.zero,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
leading: SizedBox(
|
child: Row(
|
||||||
width: 24,
|
children: [
|
||||||
height: 24,
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: sync.syncProgress <= 0
|
value: sync.syncProgress <= 0
|
||||||
? null
|
? null
|
||||||
@@ -196,21 +237,21 @@ class _SyncProgressMenuTile extends StatelessWidget {
|
|||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
|
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium,
|
||||||
color: colors.onSurface,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'),
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _AccountMenuAction { account, logout }
|
|
||||||
|
|
||||||
List<Widget> bankAppBarActions({
|
List<Widget> bankAppBarActions({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
VoidCallback? onRefresh,
|
VoidCallback? onRefresh,
|
||||||
|
|||||||
@@ -97,4 +97,40 @@ void main() {
|
|||||||
]));
|
]));
|
||||||
expect(merged.first.date, '2024-01-03');
|
expect(merged.first.date, '2024-01-03');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bank API merge keeps cloud-only txs outside the API window', () {
|
||||||
|
const cloudOnly = BankTransaction(
|
||||||
|
date: '2020-06-01',
|
||||||
|
description: 'Old cloud tx',
|
||||||
|
amount: -20,
|
||||||
|
currency: 'EUR',
|
||||||
|
pending: false,
|
||||||
|
);
|
||||||
|
const recentShared = BankTransaction(
|
||||||
|
date: '2026-09-01',
|
||||||
|
description: 'Recent Shop',
|
||||||
|
amount: -5,
|
||||||
|
currency: 'EUR',
|
||||||
|
pending: false,
|
||||||
|
);
|
||||||
|
const apiOnly = BankTransaction(
|
||||||
|
date: '2026-09-02',
|
||||||
|
description: 'New API tx',
|
||||||
|
amount: -3,
|
||||||
|
currency: 'EUR',
|
||||||
|
pending: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same order as BankController._loadLinkedData: API first, then existing.
|
||||||
|
final afterRefresh = mergeBankTransactionLists(
|
||||||
|
[recentShared, apiOnly],
|
||||||
|
[cloudOnly, recentShared],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(afterRefresh.length, 3);
|
||||||
|
expect(
|
||||||
|
afterRefresh.map((t) => t.description),
|
||||||
|
containsAll(['Old cloud tx', 'Recent Shop', 'New API tx']),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ void main() {
|
|||||||
await tester.pumpWidget(MyApp(storage: storage, auth: auth));
|
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);
|
await pumpApp(tester);
|
||||||
|
|
||||||
expect(find.text(kAppName), findsWidgets);
|
expect(find.text(kAppName), findsWidgets);
|
||||||
@@ -33,9 +34,9 @@ void main() {
|
|||||||
await tester.pump(const Duration(milliseconds: 450));
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
await tester.pump(const Duration(milliseconds: 350));
|
await tester.pump(const Duration(milliseconds: 350));
|
||||||
|
|
||||||
expect(find.text('Home'), findsWidgets);
|
expect(find.text('Sign in'), findsWidgets);
|
||||||
expect(find.text('Overview'), findsOneWidget);
|
expect(find.textContaining('Welcome to'), findsOneWidget);
|
||||||
expect(find.byTooltip('Settings'), findsOneWidget);
|
expect(find.text('Home'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ConnectStart.fromJson maps authUrl and state', () {
|
test('ConnectStart.fromJson maps authUrl and state', () {
|
||||||
@@ -116,6 +117,7 @@ void main() {
|
|||||||
test('look-ahead applies monthly recurring amounts', () {
|
test('look-ahead applies monthly recurring amounts', () {
|
||||||
final items = [
|
final items = [
|
||||||
const RecurringItem(
|
const RecurringItem(
|
||||||
|
templateId: 'salary',
|
||||||
description: 'Salary',
|
description: 'Salary',
|
||||||
amount: 2000,
|
amount: 2000,
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
@@ -123,6 +125,7 @@ void main() {
|
|||||||
labelIds: ['salary'],
|
labelIds: ['salary'],
|
||||||
),
|
),
|
||||||
const RecurringItem(
|
const RecurringItem(
|
||||||
|
templateId: 'rent',
|
||||||
description: 'Rent',
|
description: 'Rent',
|
||||||
amount: -900,
|
amount: -900,
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
|
|||||||
Reference in New Issue
Block a user