From c6d1fdb19e149f682b397e7790f1c3be9c0a16d8 Mon Sep 17 00:00:00 2001 From: davmar Date: Wed, 9 Sep 2026 19:59:38 +0200 Subject: [PATCH] sign in with pocketbase working --- .env | 6 + .env.example | 6 + .gitignore | 4 + lib/main.dart | 12 +- lib/models/transaction_meta.dart | 19 +- lib/providers/auth_provider.dart | 87 ++++ lib/providers/tx_meta_provider.dart | 47 +- lib/screens/account_screen.dart | 90 ++++ lib/screens/login_screen.dart | 156 ++++++ lib/screens/overview_screen.dart | 5 +- lib/screens/transaction_detail_screen.dart | 16 + lib/screens/transactions_screen.dart | 2 + lib/services/ingtrak_api.dart | 5 +- lib/services/storage_service.dart | 10 + lib/utils/app_config.dart | 66 +++ lib/utils/app_theme.dart | 3 - lib/utils/constants.dart | 26 +- lib/utils/navigation.dart | 80 +++ lib/widgets/label_filter_dialog.dart | 534 ++++++++++----------- lib/widgets/similar_match_dialog.dart | 11 +- pubspec.lock | 24 + pubspec.yaml | 9 +- test/filter_detail_smoke_test.dart | 147 ++++++ test/filter_sheet_test.dart | 85 ++++ test/overview_smoke_test.dart | 83 ++++ test/theme_smoke_test.dart | 39 ++ test/widget_test.dart | 7 +- 27 files changed, 1267 insertions(+), 312 deletions(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 lib/providers/auth_provider.dart create mode 100644 lib/screens/account_screen.dart create mode 100644 lib/screens/login_screen.dart create mode 100644 lib/utils/app_config.dart create mode 100644 test/filter_detail_smoke_test.dart create mode 100644 test/filter_sheet_test.dart create mode 100644 test/overview_smoke_test.dart create mode 100644 test/theme_smoke_test.dart diff --git a/.env b/.env new file mode 100644 index 0000000..c79b011 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# App endpoints (no secrets). Copy from `.env.example` if missing. +# Optional overrides: flutter run --dart-define=POCKETBASE_URL=https://other.host + +API_BASE_URL=https://ingtrak.satoshinakamoto.win +POCKETBASE_URL=https://pb.satoshinakamoto.win +POCKETBASE_USERS_COLLECTION=usersapp diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e14e51f --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# App endpoints (no secrets). Copy to `.env` if needed. +# Optional overrides: flutter run --dart-define=POCKETBASE_URL=https://other.host + +API_BASE_URL=https://ingtrak.satoshinakamoto.win +POCKETBASE_URL=https://pb.satoshinakamoto.win +POCKETBASE_USERS_COLLECTION=usersapp diff --git a/.gitignore b/.gitignore index 79f7eca..76b6146 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Local overrides — copy from `.env.example`. Public URLs are fine to commit; +# never put passwords or API secrets here. +# .env + # Miscellaneous *.class *.log diff --git a/lib/main.dart b/lib/main.dart index 44803f3..8a2f7dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,30 +7,37 @@ import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'providers/auth_provider.dart'; import 'providers/bank_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/tx_meta_provider.dart'; import 'screens/splash_screen.dart'; import 'services/storage_service.dart'; +import 'utils/app_config.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; import 'widgets/skip_build_when_covered.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await AppConfig.load(); final storage = StorageService(); await storage.init(); - runApp(MyApp(storage: storage)); + final auth = AuthController(storage); + await auth.init(); + + runApp(MyApp(storage: storage, auth: auth)); } /// Root widget. Theme and bank controllers live here so every route /// can `context.watch` them. class MyApp extends StatefulWidget { - const MyApp({super.key, required this.storage}); + const MyApp({super.key, required this.storage, required this.auth}); final StorageService storage; + final AuthController auth; @override State createState() => _MyAppState(); @@ -80,6 +87,7 @@ class _MyAppState extends State { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider.value(value: widget.auth), ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)), ChangeNotifierProvider(create: (_) => TxMetaController(widget.storage)), ChangeNotifierProvider.value(value: _bank), diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart index 829cfaa..72ece5f 100644 --- a/lib/models/transaction_meta.dart +++ b/lib/models/transaction_meta.dart @@ -217,12 +217,16 @@ class TxMeta { const TxMeta({ this.labelIds = const [], this.recurring = false, + this.internalTransfer = false, this.notes = '', }); /// Zero or more label ids on this transaction. final List labelIds; final bool recurring; + + /// Move between own accounts — excluded from income/expense cashflow. + final bool internalTransfer; final String notes; static const empty = TxMeta(); @@ -239,11 +243,13 @@ class TxMeta { List? labelIds, bool clearLabels = false, bool? recurring, + bool? internalTransfer, String? notes, }) { return TxMeta( labelIds: clearLabels ? const [] : (labelIds ?? this.labelIds), recurring: recurring ?? this.recurring, + internalTransfer: internalTransfer ?? this.internalTransfer, notes: notes ?? this.notes, ); } @@ -254,6 +260,7 @@ class TxMeta { 'labelId': primaryLabelId, 'category': primaryLabelId, 'recurring': recurring, + 'internalTransfer': internalTransfer, 'notes': notes, }; @@ -261,6 +268,7 @@ class TxMeta { return TxMeta( labelIds: _parseLabelIds(json), recurring: json['recurring'] as bool? ?? false, + internalTransfer: json['internalTransfer'] as bool? ?? false, notes: json['notes'] as String? ?? '', ); } @@ -290,22 +298,29 @@ String? _migrateLegacyCategory(String? name) { return name; } -/// Notes / recurring template keyed by exact merchant+amount match. +/// Notes / recurring / transfer template keyed by exact merchant+amount match. class TxExactTemplate { - const TxExactTemplate({this.notes = '', this.recurring = false}); + const TxExactTemplate({ + this.notes = '', + this.recurring = false, + this.internalTransfer = false, + }); final String notes; final bool recurring; + final bool internalTransfer; Map toJson() => { 'notes': notes, 'recurring': recurring, + 'internalTransfer': internalTransfer, }; factory TxExactTemplate.fromJson(Map json) { return TxExactTemplate( notes: json['notes'] as String? ?? '', recurring: json['recurring'] as bool? ?? false, + internalTransfer: json['internalTransfer'] as bool? ?? false, ); } } diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 0000000..8477881 --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -0,0 +1,87 @@ +/// PocketBase sign-in state. Local tx/bank data is unchanged by auth. +library; + +import 'package:flutter/foundation.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../services/storage_service.dart'; +import '../utils/constants.dart'; + +/// Owns the PocketBase client, session persistence, and login/logout. +class AuthController extends ChangeNotifier { + AuthController(this._storage); + + final StorageService _storage; + PocketBase? _client; + + PocketBase get client { + final pb = _client; + if (pb == null) { + throw StateError('AuthController.init() must be called before use.'); + } + return pb; + } + + bool get isLoggedIn => _client?.authStore.isValid ?? false; + + RecordModel? get user => _client?.authStore.record; + + String? get email { + final value = user?.getStringValue('email'); + return value == null || value.isEmpty ? null : value; + } + + String? get name { + final value = user?.getStringValue('name'); + return value == null || value.isEmpty ? null : value; + } + + String get displayName { + final n = name; + if (n != null && n.isNotEmpty) return n; + final e = email; + if (e != null && e.isNotEmpty) return e.split('@').first; + return 'Account'; + } + + String get initials { + final source = name ?? email ?? '?'; + final parts = source.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } + if (source.isNotEmpty) return source[0].toUpperCase(); + return '?'; + } + + Future init() async { + final store = AsyncAuthStore( + save: (String data) async { + if (data.isEmpty) { + await _storage.clearPocketBaseAuth(); + } else { + await _storage.setPocketBaseAuth(data); + } + }, + initial: _storage.pocketBaseAuth, + ); + _client = PocketBase(kPocketBaseUrl, authStore: store); + _client!.authStore.onChange.listen((_) => notifyListeners()); + notifyListeners(); + } + + Future login({ + required String email, + required String password, + }) async { + await client + .collection(kPocketBaseUsersCollection) + .authWithPassword(email.trim(), password); + notifyListeners(); + } + + Future logout() async { + client.authStore.clear(); + notifyListeners(); + } +} diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index 152f05d..ee0a438 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -278,11 +278,14 @@ class TxMetaController extends ChangeNotifier { : (inheritedNotes ?? '')); final recurring = stored?.recurring ?? template?.recurring ?? false; + final internalTransfer = + stored?.internalTransfer ?? template?.internalTransfer ?? false; return TxMeta( labelIds: List.of(labelIds), notes: notes, recurring: recurring, + internalTransfer: internalTransfer, ); } @@ -570,6 +573,29 @@ class TxMetaController extends ChangeNotifier { tx.exactKey: TxExactTemplate( notes: existing.notes.isNotEmpty ? existing.notes : current.notes, recurring: recurring, + internalTransfer: + existing.internalTransfer || current.internalTransfer, + ), + }; + _schedulePersist(); + } + + Future setInternalTransfer( + BankTransaction tx, + bool internalTransfer, + ) async { + final current = metaFor(tx); + _byId = { + ..._byId, + tx.id: current.copyWith(internalTransfer: internalTransfer), + }; + final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); + _exactTemplates = { + ..._exactTemplates, + tx.exactKey: TxExactTemplate( + notes: existing.notes.isNotEmpty ? existing.notes : current.notes, + recurring: existing.recurring || current.recurring, + internalTransfer: internalTransfer, ), }; _schedulePersist(); @@ -604,7 +630,12 @@ class TxMetaController extends ChangeNotifier { if (!applying) { final current = metaFor(tx); final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); - if (trimmed.isEmpty && !existing.recurring && !current.recurring) { + final keepTemplate = trimmed.isNotEmpty || + existing.recurring || + current.recurring || + existing.internalTransfer || + current.internalTransfer; + if (!keepTemplate) { _exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey); } else { _exactTemplates = { @@ -612,6 +643,8 @@ class TxMetaController extends ChangeNotifier { tx.exactKey: TxExactTemplate( notes: trimmed, recurring: existing.recurring || current.recurring, + internalTransfer: + existing.internalTransfer || current.internalTransfer, ), }; } @@ -641,6 +674,10 @@ class TxMetaController extends ChangeNotifier { return setRecurring(tx, !metaFor(tx).recurring); } + Future toggleInternalTransfer(BankTransaction tx) { + return setInternalTransfer(tx, !metaFor(tx).internalTransfer); + } + /// Search description, notes, label name, and date. List search( List txs, @@ -751,7 +788,9 @@ class TxMetaController extends ChangeNotifier { final totals = {}; for (final tx in txs) { if (tx.amount >= 0) continue; - final ids = metaFor(tx).labelIds; + final meta = metaFor(tx); + if (meta.internalTransfer) continue; + final ids = meta.labelIds; if (ids.isEmpty) continue; final amount = tx.amount.abs(); for (final id in ids) { @@ -766,7 +805,8 @@ class TxMetaController extends ChangeNotifier { var total = 0.0; for (final tx in txs) { if (tx.amount >= 0) continue; - if (!metaFor(tx).hasLabel) continue; + final meta = metaFor(tx); + if (meta.internalTransfer || !meta.hasLabel) continue; total += tx.amount.abs(); } return total; @@ -787,6 +827,7 @@ class TxMetaController extends ChangeNotifier { } for (final tx in txs) { if (tx.amount >= 0) continue; + if (metaFor(tx).internalTransfer) continue; final parsed = DateTime.tryParse(tx.date); if (parsed == null) continue; if (parsed.year != month.year || parsed.month != month.month) continue; diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart new file mode 100644 index 0000000..8b7d2ce --- /dev/null +++ b/lib/screens/account_screen.dart @@ -0,0 +1,90 @@ +/// Signed-in account details and sign-out. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/auth_provider.dart'; +import '../utils/constants.dart'; +import '../widgets/responsive_body.dart'; + +class AccountScreen extends StatelessWidget { + const AccountScreen({super.key}); + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Account')), + body: ResponsiveBody( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 32), + children: [ + Center( + child: CircleAvatar( + radius: 36, + backgroundColor: colors.primaryContainer, + child: Text( + auth.initials, + style: theme.textTheme.headlineSmall?.copyWith( + color: colors.onPrimaryContainer, + ), + ), + ), + ), + const SizedBox(height: 16), + Text( + auth.displayName, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall, + ), + if (auth.email case final email?) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + email, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 24), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.cloud_outlined), + title: const Text('PocketBase server'), + subtitle: Text(kPocketBaseUrl), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.phone_android_outlined), + title: const Text('Local data'), + subtitle: const Text( + 'Labels, notes, and bank cache remain on this device.', + ), + ), + ], + ), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: () async { + await auth.logout(); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.logout), + label: const Text('Sign out'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..19ec87c --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,156 @@ +/// Email/password sign-in against PocketBase. +library; + +import 'package:flutter/material.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:provider/provider.dart'; + +import '../providers/auth_provider.dart'; +import '../utils/constants.dart'; +import '../widgets/app_text_field.dart'; +import '../widgets/responsive_body.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _busy = false; + bool _obscurePassword = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_busy) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _busy = true); + final auth = context.read(); + try { + await auth.login( + email: _emailController.text, + password: _passwordController.text, + ); + if (!mounted) return; + Navigator.of(context).pop(true); + } on ClientException catch (error) { + if (!mounted) return; + final message = error.response['message'] as String? ?? + 'Sign-in failed. Check email and password.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not reach $kPocketBaseUrl')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Sign in')), + body: ResponsiveBody( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 32), + children: [ + Text( + 'Sync account', + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Sign in to link this device with PocketBase. Your labels, ' + 'notes, and bank cache stay on this phone until sync is added.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + Form( + key: _formKey, + child: Column( + children: [ + AppTextField( + controller: _emailController, + label: 'Email', + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofocus: true, + validator: (value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) return 'Enter your email'; + if (!text.contains('@')) return 'Enter a valid email'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter your password'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Password', + suffixIcon: IconButton( + tooltip: _obscurePassword + ? 'Show password' + : 'Hide password', + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _busy ? null : _submit, + icon: _busy + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onPrimary, + ), + ) + : const Icon(Icons.login), + label: Text(_busy ? 'Signing in…' : 'Sign in'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 3c4d7a2..f2c0732 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -212,7 +212,7 @@ class _OverviewScreenState extends State { Map> labelAmountByPeriod = const {}; if (_metric == _MetricMode.flow) { - monthly = _flowTotals(txs, periods, _period); + monthly = _flowTotals(txs, meta, periods, _period); } else { final labels = _labelAggregates( txs, @@ -478,6 +478,7 @@ String _periodAxisLabel(DateTime period, _PeriodMode mode) { Map _flowTotals( List txs, + TxMetaController meta, List periods, _PeriodMode mode, ) { @@ -486,6 +487,7 @@ Map _flowTotals( for (final key in keys) key: (income: 0.0, expense: 0.0), }; for (final tx in txs) { + if (meta.metaFor(tx).internalTransfer) continue; final date = _txDate(tx); if (date == null) continue; final key = _periodKey(date, mode); @@ -545,6 +547,7 @@ _LabelAggregates _labelAggregates( final filtering = includedLabelIds.isNotEmpty; for (final tx in txs) { + if (meta.metaFor(tx).internalTransfer) continue; if (!txMatchesFlow(tx, flow)) continue; final date = _txDate(tx); if (date == null) continue; diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart index 7874082..e365cbc 100644 --- a/lib/screens/transaction_detail_screen.dart +++ b/lib/screens/transaction_detail_screen.dart @@ -397,6 +397,22 @@ class _TransactionDetailScreenState extends State { context.read().setRecurring(tx, value); }, ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.swap_horiz), + title: const Text('Internal transfer'), + subtitle: const Text( + 'Between your own accounts — excluded from income/expense', + ), + value: meta.internalTransfer, + onChanged: _busy + ? null + : (value) { + context + .read() + .setInternalTransfer(tx, value); + }, + ), const SizedBox(height: 16), Row( children: [ diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 3185c3a..6e35989 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -354,6 +354,7 @@ class _TransactionsScreenState extends State { var moneyOut = 0.0; var moneyIn = 0.0; for (final tx in filtered) { + if (metaController.metaFor(tx).internalTransfer) continue; if (tx.amount < 0) { moneyOut += tx.amount.abs(); } else if (tx.amount > 0) { @@ -952,6 +953,7 @@ class _CompactTransactionTile extends StatelessWidget { if (labels.isNotEmpty) labels.map((l) => l.name).join(', '), if (meta.recurring) 'Recurring', + if (meta.internalTransfer) 'Transfer', if (meta.hasNotes) 'Note', if (tx.pending) 'Pending', ].join(' · '), diff --git a/lib/services/ingtrak_api.dart b/lib/services/ingtrak_api.dart index 928ce82..5e7f7f5 100644 --- a/lib/services/ingtrak_api.dart +++ b/lib/services/ingtrak_api.dart @@ -10,8 +10,9 @@ import '../utils/constants.dart'; /// Talks to Enable Banking via the local ingtrak API (no app-user auth). class IngtrakApi { - IngtrakApi({http.Client? client, this.baseUrl = kApiBaseUrl}) - : _client = client ?? http.Client(); + IngtrakApi({http.Client? client, String? baseUrl}) + : _client = client ?? http.Client(), + baseUrl = baseUrl ?? kApiBaseUrl; final http.Client _client; final String baseUrl; diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index b08dd33..52928db 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -125,6 +125,16 @@ class StorageService { return _prefs.setBool(StorageKeys.useRemoteData, value); } + String? get pocketBaseAuth => _prefs.getString(StorageKeys.pocketBaseAuth); + + Future setPocketBaseAuth(String value) { + return _prefs.setString(StorageKeys.pocketBaseAuth, value); + } + + Future clearPocketBaseAuth() { + return _prefs.remove(StorageKeys.pocketBaseAuth); + } + BankCache? loadBankCache() { final raw = _prefs.getString(StorageKeys.bankCache); if (raw == null || raw.isEmpty) return null; diff --git a/lib/utils/app_config.dart b/lib/utils/app_config.dart new file mode 100644 index 0000000..696b571 --- /dev/null +++ b/lib/utils/app_config.dart @@ -0,0 +1,66 @@ +/// Runtime config from `.env`, with optional `--dart-define` overrides. +library; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +/// Loads once at startup from the `.env` asset (see `.env.example`). +/// Pass `--dart-define=KEY=value` to override for CI / one-off builds. +abstract final class AppConfig { + static var _loaded = false; + + static late String apiBaseUrl; + static late String pocketBaseUrl; + static late String pocketBaseUsersCollection; + + static Future load() async { + if (_loaded) return; + try { + await dotenv.load(fileName: '.env', isOptional: true); + if (dotenv.env.isEmpty) { + await dotenv.load(fileName: '.env.example', isOptional: true); + } + } catch (_) { + // Missing asset in tests is fine — use fallbacks / loadForTest. + } + + apiBaseUrl = _resolve( + dartDefine: const String.fromEnvironment('API_BASE_URL'), + envKey: 'API_BASE_URL', + fallback: 'https://ingtrak.satoshinakamoto.win', + ); + pocketBaseUrl = _resolve( + dartDefine: const String.fromEnvironment('POCKETBASE_URL'), + envKey: 'POCKETBASE_URL', + fallback: 'http://127.0.0.1:8090', + ); + pocketBaseUsersCollection = _resolve( + dartDefine: const String.fromEnvironment('POCKETBASE_USERS_COLLECTION'), + envKey: 'POCKETBASE_USERS_COLLECTION', + fallback: 'usersapp', + ); + _loaded = true; + } + + /// For widget tests that don't go through [main]. + static void loadForTest({ + String apiBaseUrl = 'https://ingtrak.satoshinakamoto.win', + String pocketBaseUrl = 'http://127.0.0.1:8090', + String pocketBaseUsersCollection = 'usersapp', + }) { + AppConfig.apiBaseUrl = apiBaseUrl; + AppConfig.pocketBaseUrl = pocketBaseUrl; + AppConfig.pocketBaseUsersCollection = pocketBaseUsersCollection; + _loaded = true; + } + + static String _resolve({ + required String dartDefine, + required String envKey, + required String fallback, + }) { + if (dartDefine.isNotEmpty) return dartDefine; + final fromEnv = dotenv.maybeGet(envKey)?.trim(); + if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; + return fallback; + } +} diff --git a/lib/utils/app_theme.dart b/lib/utils/app_theme.dart index 829a6c0..5695ec8 100644 --- a/lib/utils/app_theme.dart +++ b/lib/utils/app_theme.dart @@ -240,9 +240,6 @@ abstract final class AppTheme { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), - side: BorderSide( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), ), side: BorderSide( color: colorScheme.outlineVariant.withValues(alpha: 0.5), diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 60dd8e4..a9781f5 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,6 +1,8 @@ /// Shared string keys, timings, and layout breakpoints used across the app. library; +import 'app_config.dart'; + /// Human-readable name shown in the splash screen, app bars, and About tile. const String kAppName = 'IngTrak'; @@ -10,18 +12,14 @@ const String kPackageName = 'com.example.myapp'; /// How long the splash screen stays visible before navigating to the shell. const Duration kSplashDuration = Duration(seconds: 2); -/// Base URL for the personal ingtrak FastAPI backend. -/// -/// Override at build/run time: -/// `flutter run --dart-define=API_BASE_URL=https://ingtrak.satoshinakamoto.win` -/// -/// Local defaults (when override omitted): -/// - Android emulator → `http://10.0.2.2:8000` -/// - Physical device on LAN → `http://192.168.x.x:8000` -const String kApiBaseUrl = String.fromEnvironment( - 'API_BASE_URL', - defaultValue: 'https://ingtrak.satoshinakamoto.win', -); +/// FastAPI bank backend. From `.env` / `--dart-define=API_BASE_URL=…`. +String get kApiBaseUrl => AppConfig.apiBaseUrl; + +/// PocketBase server. From `.env` / `--dart-define=POCKETBASE_URL=…`. +String get kPocketBaseUrl => AppConfig.pocketBaseUrl; + +/// PocketBase auth collection name (default `usersapp`). +String get kPocketBaseUsersCollection => AppConfig.pocketBaseUsersCollection; /// Enable Banking redirect derived from [kApiBaseUrl]. /// Must match backend `REDIRECT_URI` and the Control Panel whitelist. @@ -32,6 +30,7 @@ String get kEnableBankingRedirectUri { : kApiBaseUrl; return '$base/api/connect/callback'; } + /// Optional app-scheme bounce used after the http callback (not registered /// with Enable Banking). const String kBankRedirectScheme = 'ingtrak'; @@ -46,6 +45,9 @@ abstract final class StorageKeys { static const txMetaLegacy = 'tx_meta_v1'; static const bankCache = 'bank_cache_v1'; static const importedTransactions = 'imported_txs_v1'; + + /// Serialized PocketBase auth token + user record (separate from tx data). + static const pocketBaseAuth = 'pb_auth_v1'; } /// Named routes registered on [MaterialApp]. diff --git a/lib/utils/navigation.dart b/lib/utils/navigation.dart index 08a457d..ec2142b 100644 --- a/lib/utils/navigation.dart +++ b/lib/utils/navigation.dart @@ -2,9 +2,88 @@ library; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../screens/account_screen.dart'; +import '../screens/login_screen.dart'; import '../screens/settings_screen.dart'; +Widget authAppBarAction(BuildContext context) { + final auth = context.watch(); + + if (auth.isLoggedIn) { + return PopupMenuButton<_AccountMenuAction>( + tooltip: 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(); + } + }, + itemBuilder: (context) { + final email = auth.email; + return [ + if (email != null) + PopupMenuItem<_AccountMenuAction>( + enabled: false, + child: Text( + email, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + 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: 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, + ), + ), + ), + ), + ); + } + + return IconButton( + tooltip: 'Sign in', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + }, + icon: const Icon(Icons.person_outline), + ); +} + +enum _AccountMenuAction { account, logout } + List bankAppBarActions({ required BuildContext context, VoidCallback? onRefresh, @@ -17,6 +96,7 @@ List bankAppBarActions({ onPressed: refreshing ? null : onRefresh, icon: const Icon(Icons.refresh), ), + authAppBarAction(context), IconButton( tooltip: 'Settings', onPressed: () { diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index cf8d5a9..206cac5 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -91,15 +91,20 @@ Future showTransactionFilterDialog( useSafeArea: true, showDragHandle: true, builder: (dialogContext) { + final media = MediaQuery.of(dialogContext); + // Keep the sheet above the keyboard without overflowing. + final maxHeight = + (media.size.height - media.viewInsets.bottom) * 0.92; return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(dialogContext).bottom, - ), - child: _TransactionFilterDialog( - available: available, - transactions: transactions, - initial: initial, - currentMonth: currentMonth ?? monthStart(DateTime.now()), + padding: EdgeInsets.only(bottom: media.viewInsets.bottom), + child: SizedBox( + height: maxHeight.clamp(320.0, media.size.height), + child: _TransactionFilterDialog( + available: available, + transactions: transactions, + initial: initial, + currentMonth: currentMonth ?? monthStart(DateTime.now()), + ), ), ); }, @@ -201,14 +206,17 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { final showUncategorized = widget.includeUncategorized && (needle.isEmpty || kUncategorizedFilterLabel.name.toLowerCase().contains(needle)); - final maxListHeight = MediaQuery.sizeOf(context).height * 0.45; + final media = MediaQuery.of(context); + final maxContentHeight = + ((media.size.height - media.viewInsets.bottom) * 0.55) + .clamp(240.0, 560.0); return AlertDialog( title: Text(widget.title), content: SizedBox( width: double.maxFinite, + height: maxContentHeight, child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( @@ -278,8 +286,7 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { ), ], const SizedBox(height: 8), - ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxListHeight), + Expanded( child: _LabelChecklist( entries: entries, showUncategorized: showUncategorized, @@ -483,7 +490,6 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { final theme = Theme.of(context); final colors = theme.colorScheme; final meta = context.watch(); - final size = MediaQuery.sizeOf(context); final needle = _labelSearchController.text.trim().toLowerCase(); final entries = _filterLabelEntries( meta: meta, @@ -494,285 +500,253 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { kUncategorizedFilterLabel.name.toLowerCase().contains(needle); final sectionStyle = _sectionStyle(theme, colors); - // Fixed sheet height so Expanded labels get a real viewport (not a tiny 220px box). - final sheetHeight = (size.height * 0.92).clamp(420.0, size.height); - - return SizedBox( - height: sheetHeight, - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Filter transactions', - style: theme.textTheme.titleLarge, + // Parent SizedBox already bounds height (and accounts for keyboard). + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Filter transactions', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'Search, period, flow, amount, and labels.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, ), - const SizedBox(height: 4), + ), + ], + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 8), + children: [ + Text('Search text', style: sectionStyle), + const SizedBox(height: 8), + TextField( + controller: _queryController, + textInputAction: TextInputAction.search, + decoration: const InputDecoration( + hintText: 'Description, notes, amount…', + prefixIcon: Icon(Icons.search), + ), + ), + const SizedBox(height: 14), + Text('Period', style: sectionStyle), + const SizedBox(height: 8), + _RectToggleBar( + value: _monthOnly, + onChanged: (value) => setState(() => _monthOnly = value), + options: const [ + _RectToggleOption( + value: false, + label: 'All', + ), + _RectToggleOption( + value: true, + label: 'Month', + icon: Icons.calendar_month, + ), + ], + ), + if (_monthOnly) ...[ + const SizedBox(height: 8), Text( - 'Search, period, flow, amount, and labels.', - style: theme.textTheme.bodyMedium?.copyWith( + 'Uses ${formatMonthYear(widget.currentMonth)}. ' + 'Change month on the transactions list.', + style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ], - ), - ), - const SizedBox(height: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - flex: 2, - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Search text', style: sectionStyle), - const SizedBox(height: 8), - TextField( - controller: _queryController, - textInputAction: TextInputAction.search, - decoration: const InputDecoration( - hintText: 'Description, notes, amount…', - prefixIcon: Icon(Icons.search), - ), - ), - const SizedBox(height: 14), - Text('Period', style: sectionStyle), - const SizedBox(height: 8), - _RectToggleBar( - value: _monthOnly, - onChanged: (value) => - setState(() => _monthOnly = value), - options: const [ - _RectToggleOption( - value: false, - label: 'All', - ), - _RectToggleOption( - value: true, - label: 'Month', - icon: Icons.calendar_month, - ), - ], - ), - if (_monthOnly) ...[ - const SizedBox(height: 8), - Text( - 'Uses ${formatMonthYear(widget.currentMonth)}. ' - 'Change month on the transactions list.', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: 14), - Text('Income / expense', style: sectionStyle), - const SizedBox(height: 8), - _RectToggleBar( - value: _flow, - onChanged: (value) => setState(() => _flow = value), - options: const [ - _RectToggleOption( - value: TxFlowFilter.all, - label: 'All', - ), - _RectToggleOption( - value: TxFlowFilter.income, - label: 'In', - icon: Icons.south_west, - ), - _RectToggleOption( - value: TxFlowFilter.expense, - label: 'Out', - icon: Icons.north_east, - ), - ], - ), - const SizedBox(height: 14), - Text('Amount', style: sectionStyle), - const SizedBox(height: 8), - Text( - 'Exact', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - TextField( - controller: _exactAmountController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: InputDecoration( - isDense: true, - hintText: 'e.g. 12.50', - prefixIcon: const Icon(Icons.pin, size: 20), - suffixIcon: - _exactAmountController.text.trim().isEmpty - ? null - : IconButton( - tooltip: 'Clear exact', - onPressed: () { - _exactAmountController.clear(); - setState(() {}); - }, - icon: const Icon(Icons.clear, size: 18), - ), - ), - onChanged: (_) { - setState(() { - _rangeTouched = false; - _amountRange = RangeValues(0, _amountCeil); - }); + const SizedBox(height: 14), + Text('Income / expense', style: sectionStyle), + const SizedBox(height: 8), + _RectToggleBar( + value: _flow, + onChanged: (value) => setState(() => _flow = value), + options: const [ + _RectToggleOption( + value: TxFlowFilter.all, + label: 'All', + ), + _RectToggleOption( + value: TxFlowFilter.income, + label: 'In', + icon: Icons.south_west, + ), + _RectToggleOption( + value: TxFlowFilter.expense, + label: 'Out', + icon: Icons.north_east, + ), + ], + ), + const SizedBox(height: 14), + Text('Amount', style: sectionStyle), + const SizedBox(height: 8), + Text( + 'Exact', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _exactAmountController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + isDense: true, + hintText: 'e.g. 12.50', + prefixIcon: const Icon(Icons.pin, size: 20), + suffixIcon: _exactAmountController.text.trim().isEmpty + ? null + : IconButton( + tooltip: 'Clear exact', + onPressed: () { + _exactAmountController.clear(); + setState(() {}); }, + icon: const Icon(Icons.clear, size: 18), ), - const SizedBox(height: 12), - Text( - 'Range', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - _rangeTouched - ? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}' - : 'Any amount', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - RangeSlider( - values: _amountRange, - min: 0, - max: _amountCeil, - labels: RangeLabels( - _amountRange.start.toStringAsFixed(0), - _amountRange.end.toStringAsFixed(0), - ), - onChanged: (values) { - setState(() { - _exactAmountController.clear(); - _rangeTouched = true; - _amountRange = values; - }); + ), + onChanged: (_) { + setState(() { + _rangeTouched = false; + _amountRange = RangeValues(0, _amountCeil); + }); + }, + ), + const SizedBox(height: 12), + Text( + 'Range', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _rangeTouched + ? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}' + : 'Any amount', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + RangeSlider( + values: _amountRange, + min: 0, + max: _amountCeil, + labels: RangeLabels( + _amountRange.start.toStringAsFixed(0), + _amountRange.end.toStringAsFixed(0), + ), + onChanged: (values) { + setState(() { + _exactAmountController.clear(); + _rangeTouched = true; + _amountRange = values; + }); + }, + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: !_rangeTouched + ? null + : () { + setState(() { + _rangeTouched = false; + _amountRange = RangeValues(0, _amountCeil); + }); + }, + child: const Text('Reset range'), + ), + ), + const SizedBox(height: 8), + Text('Labels', style: sectionStyle), + const SizedBox(height: 8), + TextField( + controller: _labelSearchController, + decoration: InputDecoration( + isDense: true, + hintText: 'Filter labels', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: needle.isEmpty + ? null + : IconButton( + onPressed: () { + _labelSearchController.clear(); + setState(() {}); }, + icon: const Icon(Icons.clear, size: 18), ), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: !_rangeTouched - ? null - : () { - setState(() { - _rangeTouched = false; - _amountRange = - RangeValues(0, _amountCeil); - }); - }, - child: const Text('Reset range'), - ), - ), - ], - ), - ), ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Labels', style: sectionStyle), - const SizedBox(height: 8), - TextField( - controller: _labelSearchController, - decoration: InputDecoration( - isDense: true, - hintText: 'Filter labels', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: needle.isEmpty - ? null - : IconButton( - onPressed: () { - _labelSearchController.clear(); - setState(() {}); - }, - icon: const Icon(Icons.clear, size: 18), - ), - ), - onChanged: (_) => setState(() {}), - ), - Row( - children: [ - TextButton( - onPressed: () { - setState(() { - _selected - ..clear() - ..addAll(entries.map((e) => e.label.id)); - if (showUncategorized) { - _selected.add(kUncategorizedFilterId); - } - }); - }, - child: const Text('All labels'), - ), - TextButton( - onPressed: () => - setState(() => _selected.clear()), - child: const Text('No labels'), - ), - ], - ), - ], + onChanged: (_) => setState(() {}), + ), + Row( + children: [ + TextButton( + onPressed: () { + setState(() { + _selected + ..clear() + ..addAll(entries.map((e) => e.label.id)); + if (showUncategorized) { + _selected.add(kUncategorizedFilterId); + } + }); + }, + child: const Text('All labels'), ), - ), - Expanded( - flex: 3, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: _LabelChecklist( - entries: entries, - showUncategorized: showUncategorized, - selected: _selected, - onToggle: _toggle, - allowMove: false, - ), + TextButton( + onPressed: () => setState(() => _selected.clear()), + child: const Text('No labels'), ), - ), - ], - ), + ], + ), + const SizedBox(height: 4), + // Inline (no nested scroll) so the whole sheet scrolls as one. + _LabelChecklist( + entries: entries, + showUncategorized: showUncategorized, + selected: _selected, + onToggle: _toggle, + allowMove: false, + shrinkWrap: true, + ), + const SizedBox(height: 12), + ], ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FilledButton( - onPressed: _apply, - child: const Text('Apply'), - ), - const SizedBox(height: 4), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ], - ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: _apply, + child: const Text('Apply'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], ), - ], - ), + ), + ], ); } } @@ -804,6 +778,7 @@ class _LabelChecklist extends StatelessWidget { required this.selected, required this.onToggle, required this.allowMove, + this.shrinkWrap = false, }); final List entries; @@ -811,6 +786,7 @@ class _LabelChecklist extends StatelessWidget { final Set selected; final void Function(String id, bool selected) onToggle; final bool allowMove; + final bool shrinkWrap; @override Widget build(BuildContext context) { @@ -850,6 +826,10 @@ class _LabelChecklist extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(10), child: ListView.separated( + shrinkWrap: shrinkWrap, + physics: shrinkWrap + ? const NeverScrollableScrollPhysics() + : null, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: entries.length + (showUncategorized ? 1 : 0), separatorBuilder: (_, _) => Divider( diff --git a/lib/widgets/similar_match_dialog.dart b/lib/widgets/similar_match_dialog.dart index ec9edee..dd2d32e 100644 --- a/lib/widgets/similar_match_dialog.dart +++ b/lib/widgets/similar_match_dialog.dart @@ -147,14 +147,17 @@ class _SimilarMatchDialogState extends State<_SimilarMatchDialog> { final theme = Theme.of(context); final colors = theme.colorScheme; final matches = _matches; - final maxListHeight = MediaQuery.sizeOf(context).height * 0.35; + final media = MediaQuery.of(context); + final maxContentHeight = + ((media.size.height - media.viewInsets.bottom) * 0.55) + .clamp(240.0, 560.0); return AlertDialog( title: const Text('Match similar'), content: SizedBox( width: double.maxFinite, + height: maxContentHeight, child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( @@ -212,8 +215,7 @@ class _SimilarMatchDialogState extends State<_SimilarMatchDialog> { ), ), const SizedBox(height: 6), - ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxListHeight), + Expanded( child: DecoratedBox( decoration: BoxDecoration( color: colors.surfaceContainerHighest, @@ -233,7 +235,6 @@ class _SimilarMatchDialogState extends State<_SimilarMatchDialog> { ), ) : ListView.separated( - shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: matches.length, separatorBuilder: (_, _) => Divider( diff --git a/pubspec.lock b/pubspec.lock index 917bd92..9d9b516 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -142,6 +142,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 + url: "https://pub.dev" + source: hosted + version: "6.0.1" flutter_lints: dependency: "direct dev" description: @@ -192,6 +200,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -304,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pocketbase: + dependency: "direct main" + description: + name: pocketbase + sha256: "4fef3019f45d6d7c4526ee7cc0e67a72f534fcb4f80da3e3d437daf96814b61b" + url: "https://pub.dev" + source: hosted + version: "0.25.1" provider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9e31319..21f45d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: webview_flutter: ^4.14.1 fl_chart: ^1.2.0 file_picker: ^8.1.7 + pocketbase: ^0.25.1 + flutter_dotenv: ^6.0.1 dev_dependencies: flutter_test: sdk: flutter @@ -63,10 +65,9 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - .env + - .env.example # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/filter_detail_smoke_test.dart b/test/filter_detail_smoke_test.dart new file mode 100644 index 0000000..4fd236f --- /dev/null +++ b/test/filter_detail_smoke_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:myapp/models/bank.dart'; +import 'package:myapp/providers/auth_provider.dart'; +import 'package:myapp/providers/bank_provider.dart'; +import 'package:myapp/providers/theme_provider.dart'; +import 'package:myapp/providers/tx_meta_provider.dart'; +import 'package:myapp/screens/main_shell.dart'; +import 'package:myapp/screens/transaction_detail_screen.dart'; +import 'package:myapp/services/storage_service.dart'; +import 'package:myapp/utils/app_config.dart'; +import 'package:myapp/utils/app_theme.dart'; +import 'package:myapp/widgets/label_filter_dialog.dart'; + +BankTransaction tx(String desc, double amount) => BankTransaction( + date: '2026-09-01', + description: desc, + amount: amount, + currency: 'EUR', + pending: false, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StorageService storage; + late AuthController auth; + late BankController bank; + late TxMetaController meta; + + setUp(() async { + AppConfig.loadForTest(); + SharedPreferences.setMockInitialValues({}); + storage = StorageService(); + await storage.init(); + auth = AuthController(storage); + await auth.init(); + bank = BankController(storage: storage); + meta = TxMetaController(storage); + }); + + testWidgets('main shell + detail + filter', (tester) async { + FlutterError.onError = (d) => fail('${d.exceptionAsString()}\n${d.stack}'); + + // Inject remote txs via reflection-free path: use import cache if available + // Force linked-ish data by saving bank cache + await storage.saveBankCache(BankCache( + status: const ConnectStatus(status: 'linked', linked: true), + balance: const BankBalance(amount: 100, currency: 'EUR'), + transactions: [ + tx('Salary ACME', 2000), + tx('Transfer to savings', -500), + tx('Transfer from savings', 500), + tx('Grocery', -40), + ], + )); + bank = BankController(storage: storage); + await bank.refresh(); // may fail network; cache should still load on construct + // reconstruct to load cache + bank = BankController(storage: storage); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider(create: (_) => ThemeController(storage)), + ChangeNotifierProvider.value(value: meta), + ChangeNotifierProvider.value(value: bank), + ], + child: MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + home: const MainShell(), + ), + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(tester.takeException(), isNull); + + // Open filter via transactions - go to a route + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider(create: (_) => ThemeController(storage)), + ChangeNotifierProvider.value(value: meta), + ChangeNotifierProvider.value(value: bank), + ], + child: MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + home: Builder(builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + await showTransactionFilterDialog( + context, + available: meta.allLabels, + transactions: bank.transactions, + initial: const TransactionFilterSelection(labelIds: {}), + ); + }, + child: const Text('OpenFilter'), + ), + ), + ); + }), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('OpenFilter')); + await tester.pumpAndSettle(); + expect(find.text('Filter transactions'), findsOneWidget); + expect(tester.takeException(), isNull); + + // Detail + final sample = bank.transactions.isNotEmpty + ? bank.transactions.first + : tx('x', 1); + await meta.setInternalTransfer(sample, true); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider(create: (_) => ThemeController(storage)), + ChangeNotifierProvider.value(value: meta), + ChangeNotifierProvider.value(value: bank), + ], + child: MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + home: TransactionDetailScreen(tx: sample), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Internal transfer'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/filter_sheet_test.dart b/test/filter_sheet_test.dart new file mode 100644 index 0000000..ae89c01 --- /dev/null +++ b/test/filter_sheet_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:myapp/models/bank.dart'; +import 'package:myapp/providers/tx_meta_provider.dart'; +import 'package:myapp/services/storage_service.dart'; +import 'package:myapp/utils/app_theme.dart'; +import 'package:myapp/widgets/label_filter_dialog.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('filter sheet opens without layout/null errors', (tester) async { + FlutterError.onError = (d) => fail(d.exceptionAsString()); + SharedPreferences.setMockInitialValues({}); + final storage = StorageService(); + await storage.init(); + final meta = TxMetaController(storage); + + await tester.binding.setSurfaceSize(const Size(400, 800)); + addTearDown(() async { + tester.view.resetViewInsets(); + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: meta, + child: MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + showTransactionFilterDialog( + context, + available: meta.allLabels, + transactions: const [ + BankTransaction( + date: '2026-09-01', + description: 'Test', + amount: -12.5, + currency: 'EUR', + pending: false, + ), + ], + initial: const TransactionFilterSelection(labelIds: {}), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Filter transactions'), findsOneWidget); + expect(find.text('Search text'), findsOneWidget); + expect(tester.takeException(), isNull); + + // Labels live further down a lazy ListView — scroll them into view. + await tester.drag( + find.byType(ListView).first, + const Offset(0, -400), + ); + await tester.pumpAndSettle(); + expect(find.text('Labels'), findsOneWidget); + expect(tester.takeException(), isNull); + + // Simulate keyboard: viewInsets must not collapse the sheet layout. + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + expect(find.text('Filter transactions'), findsOneWidget); + expect(find.text('Apply'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/overview_smoke_test.dart b/test/overview_smoke_test.dart new file mode 100644 index 0000000..a37a7e6 --- /dev/null +++ b/test/overview_smoke_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:myapp/providers/auth_provider.dart'; +import 'package:myapp/providers/bank_provider.dart'; +import 'package:myapp/providers/theme_provider.dart'; +import 'package:myapp/providers/tx_meta_provider.dart'; +import 'package:myapp/screens/overview_screen.dart'; +import 'package:myapp/screens/transactions_screen.dart'; +import 'package:myapp/services/storage_service.dart'; +import 'package:myapp/utils/app_config.dart'; +import 'package:myapp/utils/app_theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StorageService storage; + late AuthController auth; + late BankController bank; + late TxMetaController meta; + + setUp(() async { + AppConfig.loadForTest(); + SharedPreferences.setMockInitialValues({}); + storage = StorageService(); + await storage.init(); + auth = AuthController(storage); + await auth.init(); + bank = BankController(storage: storage); + // seed a couple txs + // ignore: invalid_use_of_visible_for_testing_member + meta = TxMetaController(storage); + }); + + testWidgets('overview builds dark', (tester) async { + FlutterError.onError = (details) { + fail(details.toString()); + }; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider(create: (_) => ThemeController(storage)), + ChangeNotifierProvider.value(value: meta), + ChangeNotifierProvider.value(value: bank), + ], + child: MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + home: const OverviewScreen(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.takeException(), isNull); + expect(find.text('Overview'), findsOneWidget); + }); + + testWidgets('transactions builds dark', (tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider(create: (_) => ThemeController(storage)), + ChangeNotifierProvider.value(value: meta), + ChangeNotifierProvider.value(value: bank), + ], + child: MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + home: const TransactionsScreen(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/theme_smoke_test.dart b/test/theme_smoke_test.dart new file mode 100644 index 0000000..d966489 --- /dev/null +++ b/test/theme_smoke_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:myapp/utils/app_theme.dart'; + +void main() { + testWidgets('themes build and render SegmentedButton', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + home: Scaffold( + body: Column( + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: 0, label: Text('A')), + ButtonSegment(value: 1, label: Text('B')), + ], + selected: const {0}, + onSelectionChanged: (_) {}, + ), + NavigationBar( + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination(icon: Icon(Icons.star), label: 'Star'), + ], + selectedIndex: 1, + onDestinationSelected: (_) {}, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('A'), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 5d16c69..820664f 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:myapp/main.dart'; +import 'package:myapp/providers/auth_provider.dart'; import 'package:myapp/models/bank.dart'; import 'package:myapp/models/transaction_meta.dart'; import 'package:myapp/services/storage_service.dart'; +import 'package:myapp/utils/app_config.dart'; import 'package:myapp/utils/constants.dart'; import 'package:myapp/utils/helpers.dart'; import 'package:myapp/utils/look_ahead.dart'; @@ -13,10 +15,13 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); Future pumpApp(WidgetTester tester) async { + AppConfig.loadForTest(); SharedPreferences.setMockInitialValues({}); final storage = StorageService(); await storage.init(); - await tester.pumpWidget(MyApp(storage: storage)); + final auth = AuthController(storage); + await auth.init(); + await tester.pumpWidget(MyApp(storage: storage, auth: auth)); } testWidgets('splash shows the app name then opens Home', (tester) async {