diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d8e2f52..b47ada2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,10 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> + + + + + + + diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..44d8115 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,659 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/main.dart b/lib/main.dart index 850cfb3..394571a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,15 @@ -/// Application entry point: wires services, Provider, and [MaterialApp]. +/// Application entry point: wires services, Provider, deep links, and [MaterialApp]. library; +import 'dart:async'; + +import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'providers/item_provider.dart'; +import 'providers/bank_provider.dart'; import 'providers/theme_provider.dart'; import 'screens/splash_screen.dart'; -import 'services/item_repository.dart'; import 'services/storage_service.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; @@ -18,37 +20,66 @@ Future main() async { final storage = StorageService(); await storage.init(); - runApp( - MyApp( - storage: storage, - repository: ItemRepository(useRemoteData: storage.useRemoteData), - ), - ); + runApp(MyApp(storage: storage)); } -/// Root widget. Theme and catalog controllers live here so every route +/// Root widget. Theme and bank controllers live here so every route /// can `context.watch` them. -class MyApp extends StatelessWidget { - const MyApp({ - super.key, - required this.storage, - required this.repository, - }); +class MyApp extends StatefulWidget { + const MyApp({super.key, required this.storage}); final StorageService storage; - final ItemRepository repository; + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final BankController _bank; + late final AppLinks _appLinks; + StreamSubscription? _linkSub; + + @override + void initState() { + super.initState(); + _bank = BankController()..refresh(); + _appLinks = AppLinks(); + _listenForBankRedirects(); + } + + Future _listenForBankRedirects() async { + try { + final initial = await _appLinks.getInitialLink(); + if (initial != null) { + await _bank.handleRedirect(initial); + } + } catch (error, stackTrace) { + debugPrint('Initial deep link failed: $error\n$stackTrace'); + } + + _linkSub = _appLinks.uriLinkStream.listen( + (uri) { + unawaited(_bank.handleRedirect(uri)); + }, + onError: (Object error, StackTrace stackTrace) { + debugPrint('Deep link stream error: $error\n$stackTrace'); + }, + ); + } + + @override + void dispose() { + _linkSub?.cancel(); + _bank.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { return MultiProvider( providers: [ - ChangeNotifierProvider(create: (_) => ThemeController(storage)), - ChangeNotifierProvider( - create: (_) => ItemController( - repository: repository, - storage: storage, - )..loadItems(), - ), + ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)), + ChangeNotifierProvider.value(value: _bank), ], child: const _MaterialRoot(), ); diff --git a/lib/models/bank.dart b/lib/models/bank.dart new file mode 100644 index 0000000..142bebf --- /dev/null +++ b/lib/models/bank.dart @@ -0,0 +1,112 @@ +/// Domain models for the personal ingtrak bank-data API. +library; + +/// Result of `POST /api/connect`. +class ConnectStart { + const ConnectStart({required this.authUrl, required this.state}); + + final String authUrl; + final String state; + + factory ConnectStart.fromJson(Map json) { + return ConnectStart( + authUrl: json['authUrl'] as String? ?? '', + state: json['state'] as String? ?? '', + ); + } +} + +/// Link status from `/api/connect/status` or `/api/connect/complete`. +class ConnectStatus { + const ConnectStatus({ + required this.status, + required this.linked, + this.accountId, + this.sessionId, + this.state, + this.validUntil, + }); + + final String status; + final bool linked; + final String? accountId; + final String? sessionId; + final String? state; + final String? validUntil; + + factory ConnectStatus.fromJson(Map json) { + return ConnectStatus( + status: json['status'] as String? ?? 'unknown', + linked: json['linked'] as bool? ?? false, + accountId: json['accountId'] as String?, + sessionId: json['sessionId'] as String?, + state: json['state'] as String?, + validUntil: json['validUntil'] as String?, + ); + } + + static const disconnected = ConnectStatus( + status: 'disconnected', + linked: false, + ); +} + +/// `GET /api/balance` payload. +class BankBalance { + const BankBalance({ + required this.amount, + required this.currency, + this.balanceType, + }); + + final double amount; + final String currency; + final String? balanceType; + + factory BankBalance.fromJson(Map json) { + final rawAmount = json['amount']; + final amount = switch (rawAmount) { + num n => n.toDouble(), + String s => double.tryParse(s) ?? 0, + _ => 0.0, + }; + return BankBalance( + amount: amount, + currency: json['currency'] as String? ?? 'EUR', + balanceType: json['balanceType'] as String?, + ); + } +} + +/// One row from `GET /api/transactions`. +class BankTransaction { + const BankTransaction({ + required this.date, + required this.description, + required this.amount, + required this.currency, + required this.pending, + }); + + final String date; + final String description; + final double amount; + final String currency; + final bool pending; + + factory BankTransaction.fromJson(Map json) { + final rawAmount = json['amount']; + final amount = switch (rawAmount) { + num n => n.toDouble(), + String s => double.tryParse(s) ?? 0, + _ => 0.0, + }; + return BankTransaction( + date: json['date'] as String? ?? '', + description: json['description'] as String? ?? 'Unknown', + amount: amount, + currency: json['currency'] as String? ?? 'EUR', + pending: json['pending'] as bool? ?? false, + ); + } +} diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart new file mode 100644 index 0000000..20f9425 --- /dev/null +++ b/lib/providers/bank_provider.dart @@ -0,0 +1,180 @@ +/// [ChangeNotifier] for bank link status, balance, and transactions. +library; + +import 'package:flutter/foundation.dart'; + +import '../models/bank.dart'; +import '../services/ingtrak_api.dart'; +import '../utils/constants.dart'; + +/// App-wide banking state backed by the local ingtrak API. +class BankController extends ChangeNotifier { + BankController({IngtrakApi? api}) : _api = api ?? IngtrakApi(); + + final IngtrakApi _api; + + ConnectStatus _status = ConnectStatus.disconnected; + BankBalance? _balance; + List _transactions = const []; + + bool _isLoading = false; + bool _isConnecting = false; + String? _error; + String? _pendingState; + + ConnectStatus get status => _status; + BankBalance? get balance => _balance; + List get transactions => List.unmodifiable(_transactions); + bool get isLoading => _isLoading; + bool get isConnecting => _isConnecting; + String? get error => _error; + bool get isLinked => _status.linked; + String get apiBaseUrl => _api.baseUrl; + String? get pendingState => _pendingState; + + /// Load status; if linked, also load balance + transactions. + Future refresh() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _status = await _api.fetchStatus(); + if (_status.linked) { + await _loadLinkedData(); + } else { + _balance = null; + _transactions = const []; + } + } on IngtrakApiException catch (error, stackTrace) { + _error = error.message; + debugPrint('BankController.refresh failed: $error\n$stackTrace'); + } catch (error, stackTrace) { + _error = 'Could not reach $apiBaseUrl'; + debugPrint('BankController.refresh failed: $error\n$stackTrace'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future _loadLinkedData() async { + try { + final results = await Future.wait([ + _api.fetchBalance(), + _api.fetchTransactions(), + ]); + _balance = results[0] as BankBalance; + _transactions = results[1] as List; + _error = null; + } on IngtrakApiException catch (error) { + if (error.isNotLinked) { + _status = ConnectStatus.disconnected; + _balance = null; + _transactions = const []; + _error = error.message; + } else { + _error = error.message; + } + } + } + + /// `POST /api/connect`. Returns auth payload for the in-app WebView. + Future beginConnect() async { + _isConnecting = true; + _error = null; + notifyListeners(); + + try { + final start = await _api.startConnect(); + if (start.authUrl.isEmpty) { + throw const IngtrakApiException('Connect response missing authUrl'); + } + _pendingState = start.state; + return start; + } on IngtrakApiException catch (error, stackTrace) { + _error = error.message; + debugPrint('BankController.beginConnect failed: $error\n$stackTrace'); + return null; + } catch (error, stackTrace) { + _error = 'Could not start bank connect.'; + debugPrint('BankController.beginConnect failed: $error\n$stackTrace'); + return null; + } finally { + _isConnecting = false; + notifyListeners(); + } + } + + /// Handle optional `ingtrak://connected?...` (e.g. after HTML bounce). + Future handleRedirect(Uri uri) async { + if (uri.scheme != kBankRedirectScheme) return false; + if (uri.host != kBankRedirectHost && uri.path != '/$kBankRedirectHost') { + if (uri.host.isNotEmpty && uri.host != kBankRedirectHost) return false; + } + + final code = uri.queryParameters['code']; + if (code != null && code.isNotEmpty) { + final state = uri.queryParameters['state'] ?? _pendingState; + return completeWithCode(code: code, state: state); + } + + // Backend already exchanged the code; just refresh local state. + if (uri.queryParameters['linked'] == '1') { + await refresh(); + if (!isLinked) { + _error = + 'Returned to the app, but the backend still has no linked session. ' + 'Finish login inside the in-app WebView (do not open the email ' + 'link in another browser), then pull to refresh.'; + notifyListeners(); + } + return isLinked; + } + + final redirectError = uri.queryParameters['error']; + _error = redirectError != null + ? 'Bank auth failed: $redirectError' + : 'Bank redirect missing code'; + notifyListeners(); + return true; + } + + Future completeWithCode({ + required String code, + String? state, + }) async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _status = await _api.completeConnect( + code: code, + state: state ?? _pendingState, + ); + _pendingState = null; + if (_status.linked) { + await _loadLinkedData(); + } + return _status.linked; + } on IngtrakApiException catch (error, stackTrace) { + _error = error.message; + debugPrint('BankController.completeWithCode failed: $error\n$stackTrace'); + return false; + } catch (error, stackTrace) { + _error = 'Could not complete bank connect.'; + debugPrint('BankController.completeWithCode failed: $error\n$stackTrace'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + @override + void dispose() { + _api.close(); + super.dispose(); + } +} diff --git a/lib/screens/balance_screen.dart b/lib/screens/balance_screen.dart new file mode 100644 index 0000000..b478cef --- /dev/null +++ b/lib/screens/balance_screen.dart @@ -0,0 +1,172 @@ +/// Balance tab: connect bank CTA, current balance, and refresh. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/bank_provider.dart'; +import '../utils/connect_bank.dart'; +import '../utils/helpers.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/loading_view.dart'; +import '../widgets/responsive_body.dart'; + +class BalanceScreen extends StatelessWidget { + const BalanceScreen({super.key}); + + @override + Widget build(BuildContext context) { + final bank = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Balance'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: bank.isLoading ? null : bank.refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: RefreshIndicator( + onRefresh: bank.refresh, + child: ResponsiveBody( + child: _BalanceBody(bank: bank), + ), + ), + ); + } +} + +class _BalanceBody extends StatelessWidget { + const _BalanceBody({required this.bank}); + + final BankController bank; + + @override + Widget build(BuildContext context) { + if (bank.isLoading && bank.balance == null && !bank.isLinked) { + return const LoadingView(message: 'Checking bank link…'); + } + + if (!bank.isLinked) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + EmptyState( + icon: Icons.account_balance_outlined, + title: 'Bank not linked', + message: bank.error ?? + 'Connect opens a sandbox bank login in-app. ' + 'Stay in that WebView until it returns here. ' + 'You should then see a balance and transactions.', + actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank', + onAction: () => runConnectBank(context, bank), + ), + ], + ); + } + + if (bank.error != null && bank.balance == null) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + EmptyState( + icon: Icons.error_outline, + title: 'Could not load balance', + message: bank.error!, + actionLabel: 'Retry', + onAction: bank.refresh, + ), + ], + ); + } + + final balance = bank.balance; + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [colors.primary, colors.tertiary], + ), + boxShadow: [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.28), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current balance', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 8), + Text( + balance == null + ? '—' + : formatMoney(balance.amount, balance.currency), + style: theme.textTheme.headlineMedium?.copyWith( + color: colors.onPrimary, + ), + ), + if (balance?.balanceType != null) ...[ + const SizedBox(height: 8), + Text( + balance!.balanceType!, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.75), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 20), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.check_circle_outline, + color: colors.primary, + ), + title: const Text('Linked'), + subtitle: Text( + bank.status.accountId == null + ? bank.status.status + : 'Account ${bank.status.accountId}', + ), + ), + if (bank.error != null) ...[ + const SizedBox(height: 8), + Text( + bank.error!, + style: theme.textTheme.bodyMedium?.copyWith(color: colors.error), + ), + ], + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: bank.isConnecting + ? null + : () => runConnectBank(context, bank), + icon: const Icon(Icons.link), + label: Text(bank.isConnecting ? 'Opening…' : 'Reconnect bank'), + ), + ], + ); + } +} diff --git a/lib/screens/bank_auth_screen.dart b/lib/screens/bank_auth_screen.dart new file mode 100644 index 0000000..ac6cd13 --- /dev/null +++ b/lib/screens/bank_auth_screen.dart @@ -0,0 +1,124 @@ +/// In-app browser for Enable Banking hosted auth. +/// +/// Enable Banking only allows http(s) redirect URLs, so we keep +/// `http://localhost:8000/api/connect/callback` and capture `code` here +/// when the WebView navigates to that callback. +library; + +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Result popped from [BankAuthScreen] after bank login. +class BankAuthResult { + const BankAuthResult({this.code, this.state, this.error}); + + final String? code; + final String? state; + final String? error; + + bool get hasCode => code != null && code!.isNotEmpty; +} + +class BankAuthScreen extends StatefulWidget { + const BankAuthScreen({ + super.key, + required this.authUrl, + this.pendingState, + }); + + final String authUrl; + final String? pendingState; + + @override + State createState() => _BankAuthScreenState(); +} + +class _BankAuthScreenState extends State { + late final WebViewController _controller; + var _finishing = false; + var _loading = true; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (url) { + if (mounted) setState(() => _loading = true); + _handleUrl(url); + }, + onPageFinished: (_) { + if (mounted) setState(() => _loading = false); + }, + onNavigationRequest: (request) { + if (_handleUrl(request.url)) { + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + onWebResourceError: (error) { + // Callback to localhost often fails to load on emulators; if we + // already captured the code, ignore the load error. + if (_finishing) return; + debugPrint('Bank auth WebView error: ${error.description}'); + }, + ), + ) + ..loadRequest(Uri.parse(widget.authUrl)); + } + + /// Returns true when [url] is the Enable Banking redirect callback. + bool _handleUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null || _finishing) return false; + if (!_isConnectCallback(uri)) return false; + + _finishing = true; + final error = uri.queryParameters['error']; + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state'] ?? widget.pendingState; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Navigator.of(context).pop( + BankAuthResult( + code: code, + state: state, + error: error, + ), + ); + }); + return true; + } + + bool _isConnectCallback(Uri uri) { + final path = uri.path; + return path == '/api/connect/callback' || + path.endsWith('/api/connect/callback'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Connect bank'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: Stack( + children: [ + WebViewWidget(controller: _controller), + if (_loading) + const Align( + alignment: Alignment.topCenter, + child: LinearProgressIndicator(minHeight: 2), + ), + ], + ), + ); + } +} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 5878dbd..5f5a546 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1,14 +1,13 @@ -/// Host scaffold that owns the bottom navigation bar and its four tabs. +/// Host scaffold that owns the bottom navigation bar and its tabs. library; import 'package:flutter/material.dart'; -import 'home_screen.dart'; -import 'profile_screen.dart'; -import 'search_screen.dart'; +import 'balance_screen.dart'; import 'settings_screen.dart'; +import 'transactions_screen.dart'; -/// Switches between Home, Search, Profile, and Settings without stacking routes. +/// Switches between Balance, Transactions, and Settings without stacking routes. class MainShell extends StatefulWidget { const MainShell({super.key}); @@ -20,9 +19,8 @@ class _MainShellState extends State { int _index = 0; static const _pages = [ - HomeScreen(), - SearchScreen(), - ProfileScreen(), + BalanceScreen(), + TransactionsScreen(), SettingsScreen(), ]; @@ -38,19 +36,14 @@ class _MainShellState extends State { onDestinationSelected: (index) => setState(() => _index = index), destinations: const [ NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', + icon: Icon(Icons.account_balance_wallet_outlined), + selectedIcon: Icon(Icons.account_balance_wallet), + label: 'Balance', ), NavigationDestination( - icon: Icon(Icons.search_outlined), - selectedIcon: Icon(Icons.search), - label: 'Search', - ), - NavigationDestination( - icon: Icon(Icons.person_outline), - selectedIcon: Icon(Icons.person), - label: 'Profile', + icon: Icon(Icons.receipt_long_outlined), + selectedIcon: Icon(Icons.receipt_long), + label: 'Transactions', ), NavigationDestination( icon: Icon(Icons.settings_outlined), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 35b0c9b..4c8e7be 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,11 +1,12 @@ -/// Settings tab: theme mode, data source, and about information. +/// Settings tab: theme mode, API endpoint, and bank link status. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../providers/item_provider.dart'; +import '../providers/bank_provider.dart'; import '../providers/theme_provider.dart'; +import '../utils/connect_bank.dart'; import '../utils/constants.dart'; import '../utils/helpers.dart'; import '../widgets/responsive_body.dart'; @@ -16,7 +17,7 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { final themeController = context.watch(); - final items = context.watch(); + final bank = context.watch(); final theme = Theme.of(context); return Scaffold( @@ -56,19 +57,58 @@ class SettingsScreen extends StatelessWidget { }, ), const SizedBox(height: 28), - Text('Data', style: theme.textTheme.titleMedium), + Text('Backend', style: theme.textTheme.titleMedium), const SizedBox(height: 8), Card( - child: SwitchListTile( - title: const Text('Use remote sample API'), - subtitle: const Text( - 'When on, items are fetched with the http package from ' - 'JSONPlaceholder. Failures fall back to local mock data.', - ), - value: items.useRemoteData, - onChanged: (value) => items.setUseRemoteData(value), + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('API base URL'), + subtitle: Text(bank.apiBaseUrl), + ), + const Divider(height: 1), + ListTile( + leading: Icon( + bank.isLinked + ? Icons.link + : Icons.link_off_outlined, + ), + title: const Text('Bank link'), + subtitle: Text( + bank.isLinked + ? 'Linked · ${bank.status.status}' + : 'Not linked', + ), + trailing: bank.isLinked + ? null + : TextButton( + onPressed: bank.isConnecting + ? null + : () => runConnectBank(context, bank), + child: Text( + bank.isConnecting ? 'Opening…' : 'Connect', + ), + ), + ), + const Divider(height: 1), + const ListTile( + leading: Icon(Icons.open_in_browser_outlined), + title: Text('Enable Banking redirect'), + subtitle: Text(kEnableBankingRedirectUri), + ), + ], ), ), + const SizedBox(height: 12), + Text( + 'Enable Banking only allows http(s) redirects. Keep ' + '$kEnableBankingRedirectUri in the Control Panel and backend ' + 'REDIRECT_URI. The app captures that URL inside a WebView. ' + 'Override the API host with ' + '`--dart-define=API_BASE_URL=http://:8000`.', + style: theme.textTheme.bodyMedium, + ), const SizedBox(height: 28), Text('About', style: theme.textTheme.titleMedium), const SizedBox(height: 8), @@ -78,7 +118,7 @@ class SettingsScreen extends StatelessWidget { const ListTile( leading: Icon(Icons.apps), title: Text(kAppName), - subtitle: Text('Generic Flutter Android starter'), + subtitle: Text('Personal bank data via Enable Banking'), ), const Divider(height: 1), const ListTile( diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 3e7df2d..80525e9 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -90,7 +90,7 @@ class _SplashScreenState extends State borderRadius: BorderRadius.circular(24), ), child: Icon( - Icons.layers_rounded, + Icons.account_balance_rounded, size: 44, color: colors.onPrimary, ), @@ -103,12 +103,12 @@ class _SplashScreenState extends State ), ), const SizedBox(height: 8), - Text( - 'A Material 3 starter template', - style: textTheme.bodyLarge?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.85), + Text( + 'Your bank data, simply', + style: textTheme.bodyLarge?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.85), + ), ), - ), const SizedBox(height: 40), SizedBox( width: 28, diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart new file mode 100644 index 0000000..e616528 --- /dev/null +++ b/lib/screens/transactions_screen.dart @@ -0,0 +1,142 @@ +/// Transactions tab: recent bank movements from the ingtrak API. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/bank.dart'; +import '../providers/bank_provider.dart'; +import '../utils/connect_bank.dart'; +import '../utils/helpers.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/loading_view.dart'; +import '../widgets/responsive_body.dart'; + +class TransactionsScreen extends StatelessWidget { + const TransactionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final bank = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Transactions'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: bank.isLoading ? null : bank.refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: RefreshIndicator( + onRefresh: bank.refresh, + child: ResponsiveBody( + child: _TransactionsBody(bank: bank), + ), + ), + ); + } +} + +class _TransactionsBody extends StatelessWidget { + const _TransactionsBody({required this.bank}); + + final BankController bank; + + @override + Widget build(BuildContext context) { + if (bank.isLoading && bank.transactions.isEmpty) { + return const LoadingView(message: 'Loading transactions…'); + } + + if (!bank.isLinked) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + EmptyState( + icon: Icons.receipt_long_outlined, + title: 'No transactions yet', + message: bank.error ?? + 'Link your bank on the Balance tab to load recent activity.', + actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank', + onAction: () => runConnectBank(context, bank), + ), + ], + ); + } + + if (bank.error != null && bank.transactions.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + EmptyState( + icon: Icons.error_outline, + title: 'Could not load transactions', + message: bank.error!, + actionLabel: 'Retry', + onAction: bank.refresh, + ), + ], + ); + } + + if (bank.transactions.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + EmptyState( + icon: Icons.inbox_outlined, + title: 'No recent activity', + message: 'The backend returned an empty transaction list.', + ), + ], + ); + } + + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 8, bottom: 32), + itemCount: bank.transactions.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return _TransactionTile(tx: bank.transactions[index]); + }, + ); + } +} + +class _TransactionTile extends StatelessWidget { + const _TransactionTile({required this.tx}); + + final BankTransaction tx; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final isCredit = tx.amount >= 0; + final amountColor = isCredit ? colors.primary : colors.error; + + return Card( + child: ListTile( + title: Text( + tx.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + [ + formatIsoDate(tx.date), + if (tx.pending) 'Pending', + ].where((part) => part.isNotEmpty).join(' · '), + ), + trailing: Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.titleMedium?.copyWith(color: amountColor), + ), + ), + ); + } +} diff --git a/lib/services/ingtrak_api.dart b/lib/services/ingtrak_api.dart new file mode 100644 index 0000000..ed1aa2c --- /dev/null +++ b/lib/services/ingtrak_api.dart @@ -0,0 +1,171 @@ +/// Thin HTTP client for the personal ingtrak FastAPI backend. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/bank.dart'; +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(); + + final http.Client _client; + final String baseUrl; + + Uri _uri(String path, [Map? query]) { + final normalized = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + return Uri.parse('$normalized$path').replace(queryParameters: query); + } + + Future> _decodeObject(http.Response response) { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const IngtrakApiException('Unexpected response shape'); + } + return Future.value(decoded); + } + + void _ensureOk(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) return; + throw IngtrakApiException( + _messageFromBody(response) ?? + 'Request failed (${response.statusCode})', + statusCode: response.statusCode, + ); + } + + String? _messageFromBody(http.Response response) { + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final detail = decoded['detail']; + if (detail is String) return detail; + if (detail is Map) { + final nested = detail['enablebanking']; + if (nested is Map && nested['message'] is String) { + return nested['message'] as String; + } + if (detail['message'] is String) { + return detail['message'] as String; + } + } + } + } catch (_) { + // Fall through to generic message. + } + return null; + } + + Future health() async { + final response = await _client.get( + _uri('/health'), + headers: const {'Accept': 'application/json'}, + ); + _ensureOk(response); + final body = await _decodeObject(response); + return body['status'] == 'ok'; + } + + /// `POST /api/connect` → open [ConnectStart.authUrl] in a browser. + Future startConnect({ + String? aspspName, + String? aspspCountry, + }) async { + final payload = {}; + if (aspspName != null && aspspName.isNotEmpty) { + payload['aspspName'] = aspspName; + } + if (aspspCountry != null && aspspCountry.isNotEmpty) { + payload['aspspCountry'] = aspspCountry; + } + + final response = await _client.post( + _uri('/api/connect'), + headers: const { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: jsonEncode(payload), + ); + _ensureOk(response); + return ConnectStart.fromJson(await _decodeObject(response)); + } + + /// `POST /api/connect/complete` after the deep-link redirect. + Future completeConnect({ + required String code, + String? state, + }) async { + final payload = {'code': code}; + if (state != null && state.isNotEmpty) { + payload['state'] = state; + } + + final response = await _client.post( + _uri('/api/connect/complete'), + headers: const { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: jsonEncode(payload), + ); + _ensureOk(response); + return ConnectStatus.fromJson(await _decodeObject(response)); + } + + Future fetchStatus() async { + final response = await _client.get( + _uri('/api/connect/status'), + headers: const {'Accept': 'application/json'}, + ); + _ensureOk(response); + return ConnectStatus.fromJson(await _decodeObject(response)); + } + + Future fetchBalance() async { + final response = await _client.get( + _uri('/api/balance'), + headers: const {'Accept': 'application/json'}, + ); + _ensureOk(response); + return BankBalance.fromJson(await _decodeObject(response)); + } + + Future> fetchTransactions({int days = 90}) async { + final response = await _client.get( + _uri('/api/transactions', {'days': '$days'}), + headers: const {'Accept': 'application/json'}, + ); + _ensureOk(response); + + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw const IngtrakApiException('Unexpected transactions response'); + } + + return decoded + .whereType>() + .map(BankTransaction.fromJson) + .toList(growable: false); + } + + void close() => _client.close(); +} + +class IngtrakApiException implements Exception { + const IngtrakApiException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + bool get isNotLinked => statusCode == 409; + + @override + String toString() => message; +} diff --git a/lib/utils/connect_bank.dart b/lib/utils/connect_bank.dart new file mode 100644 index 0000000..956f957 --- /dev/null +++ b/lib/utils/connect_bank.dart @@ -0,0 +1,59 @@ +/// Shared UI helpers for bank connect actions. +library; + +import 'package:flutter/material.dart'; + +import '../providers/bank_provider.dart'; +import '../screens/bank_auth_screen.dart'; + +Future runConnectBank(BuildContext context, BankController bank) async { + final start = await bank.beginConnect(); + if (!context.mounted) return; + + if (start == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(bank.error ?? 'Could not start bank connect')), + ); + return; + } + + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BankAuthScreen( + authUrl: start.authUrl, + pendingState: start.state, + ), + ), + ); + + if (!context.mounted) return; + if (result == null) return; + + if (result.error != null && result.error!.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Bank auth failed: ${result.error}')), + ); + return; + } + + if (!result.hasCode) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Bank redirect missing code')), + ); + return; + } + + final linked = await bank.completeWithCode( + code: result.code!, + state: result.state, + ); + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + linked ? 'Bank linked' : (bank.error ?? 'Could not complete connect'), + ), + ), + ); +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index dcacb69..bcd2b25 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -2,7 +2,7 @@ library; /// Human-readable name shown in the splash screen, app bars, and About tile. -const String kAppName = 'MyApp'; +const String kAppName = 'IngTrak'; /// Package / Android application id. const String kPackageName = 'com.example.myapp'; @@ -10,6 +10,32 @@ 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=http://192.168.1.114:8000` +/// +/// Defaults: +/// - Android emulator → `http://10.0.2.2:8000` (host loopback) +/// - iOS Simulator → `http://127.0.0.1:8000` +/// - Physical device → Mac LAN IP, e.g. `http://192.168.1.114:8000` +const String kApiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://10.0.2.2:8000', +); + +/// Enable Banking only accepts http(s) redirect URLs (not custom schemes). +/// Keep this exact value in the Control Panel and backend `REDIRECT_URI`. +/// The app opens auth in a WebView and intercepts this callback to read `code`. +const String kEnableBankingRedirectUri = + 'http://localhost:8000/api/connect/callback'; + +/// Optional app-scheme bounce used after the http callback (not registered +/// with Enable Banking). +const String kBankRedirectScheme = 'ingtrak'; +const String kBankRedirectHost = 'connected'; +const String kBankRedirectUri = '$kBankRedirectScheme://$kBankRedirectHost'; + /// SharedPreferences keys. abstract final class StorageKeys { static const themeMode = 'theme_mode'; diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart index 4d6e0fb..17f8273 100644 --- a/lib/utils/helpers.dart +++ b/lib/utils/helpers.dart @@ -57,3 +57,31 @@ String labelForThemeMode(ThemeMode mode) { ThemeMode.system => 'System', }; } + +/// Formats a money amount with currency code, e.g. `1 234,56 EUR`. +String formatMoney(double amount, String currency) { + final sign = amount < 0 ? '-' : ''; + final abs = amount.abs(); + final fixed = abs.toStringAsFixed(2); + final parts = fixed.split('.'); + final whole = parts[0]; + final fraction = parts.length > 1 ? parts[1] : '00'; + + final buffer = StringBuffer(); + for (var i = 0; i < whole.length; i++) { + final fromEnd = whole.length - i; + buffer.write(whole[i]); + if (fromEnd > 1 && fromEnd % 3 == 1) { + buffer.write('\u00a0'); + } + } + return '$sign${buffer.toString()},$fraction $currency'; +} + +/// Formats an ISO date string (`yyyy-MM-dd` or full ISO) for list rows. +String formatIsoDate(String raw) { + if (raw.isEmpty) return ''; + final parsed = DateTime.tryParse(raw); + if (parsed == null) return raw; + return formatDate(parsed); +} diff --git a/pubspec.lock b/pubspec.lock index 5600031..4ed739a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + app_links: + dependency: "direct main" + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" async: dependency: transitive description: @@ -96,6 +128,14 @@ packages: description: flutter source: sdk version: "0.0.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" http: dependency: "direct main" description: @@ -373,6 +413,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730 + url: "https://pub.dev" + source: hosted + version: "3.26.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 20fabd9..6435b13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: provider: ^6.1.5+1 # ChangeNotifier state for theme and catalog http: ^1.6.0 # Placeholder network client (JSONPlaceholder) shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle + app_links: ^7.2.1 + webview_flutter: ^4.14.1 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 9793445..2452777 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:myapp/main.dart'; -import 'package:myapp/models/item.dart'; -import 'package:myapp/services/item_repository.dart'; +import 'package:myapp/models/bank.dart'; import 'package:myapp/services/storage_service.dart'; import 'package:myapp/utils/constants.dart'; +import 'package:myapp/utils/helpers.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -13,15 +13,10 @@ void main() { SharedPreferences.setMockInitialValues({}); final storage = StorageService(); await storage.init(); - await tester.pumpWidget( - MyApp( - storage: storage, - repository: ItemRepository(), - ), - ); + await tester.pumpWidget(MyApp(storage: storage)); } - testWidgets('splash shows the app name then opens Home', (tester) async { + testWidgets('splash shows the app name then opens Balance', (tester) async { await pumpApp(tester); expect(find.text(kAppName), findsWidgets); @@ -30,37 +25,30 @@ void main() { await tester.pump(const Duration(milliseconds: 450)); await tester.pump(const Duration(milliseconds: 350)); - expect(find.text('Home'), findsWidgets); - expect(find.text('Featured items'), findsOneWidget); - expect(find.text('Search'), findsOneWidget); - expect(find.text('Profile'), findsOneWidget); + expect(find.text('Balance'), findsWidgets); + expect(find.text('Transactions'), findsOneWidget); expect(find.text('Settings'), findsOneWidget); }); - testWidgets('tapping a list card opens the detail screen', (tester) async { - await pumpApp(tester); - await tester.pump(kSplashDuration); - await tester.pump(const Duration(milliseconds: 450)); - await tester.pump(const Duration(milliseconds: 350)); - - await tester.tap(find.text('Material 3 surfaces')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - - expect(find.text('Details'), findsOneWidget); - expect(find.text('Back to list'), findsOneWidget); + test('ConnectStart.fromJson maps authUrl and state', () { + final start = ConnectStart.fromJson({ + 'authUrl': 'https://example.test/auth', + 'state': 'abc', + }); + expect(start.authUrl, 'https://example.test/auth'); + expect(start.state, 'abc'); }); - test('Item.fromJson maps a JSONPlaceholder post', () { - final item = Item.fromJson({ - 'id': 3, - 'title': 'hello world', - 'body': 'First sentence. Second sentence.', + test('BankTransaction.fromJson maps API fields', () { + final tx = BankTransaction.fromJson({ + 'date': '2026-09-01', + 'description': 'Coffee', + 'amount': -3.5, + 'currency': 'EUR', + 'pending': false, }); - - expect(item.id, 'remote-3'); - expect(item.title, 'Hello world'); - expect(item.subtitle, 'First sentence.'); - expect(item.category, ItemCategory.lifestyle); + expect(tx.description, 'Coffee'); + expect(tx.amount, -3.5); + expect(formatMoney(tx.amount, tx.currency), contains('EUR')); }); }