From c4f0ca785e04ebed8de3e2860c5ec7d8191fec65 Mon Sep 17 00:00:00 2001 From: davmar Date: Thu, 10 Sep 2026 22:58:52 +0200 Subject: [PATCH] user admin capability --- README.md | 7 + lib/providers/auth_provider.dart | 18 ++ lib/providers/bank_provider.dart | 34 +++ lib/screens/account_screen.dart | 67 +++++- .../admin_create_transaction_dialog.dart | 226 ++++++++++++++++++ lib/widgets/home_filter_widget_card.dart | 58 +++-- .../1740000009_ingtrak_usersapp_is_admin.js | 31 +++ 7 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 lib/widgets/admin_create_transaction_dialog.dart create mode 100644 pocketbase/pb_migrations/1740000009_ingtrak_usersapp_is_admin.js diff --git a/README.md b/README.md index 16c7eac..8964d8c 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,16 @@ Migrations live in [`pocketbase/pb_migrations/`](pocketbase/pb_migrations/). The | `1740000004`–`0006` | `home_filter_widgets` (+ half width, budget fields) | | `1740000007` | `app_prefs` (Home section toggles + Look ahead years) | | `1740000008` | `home_filter_widgets.label_match` (AND/OR) | +| `1740000009` | `usersapp.is_admin` (admin accounts) | Built-in labels stay in the app; only **custom** labels sync. Rows are scoped per user (`user = @request.auth.id`). +### Admin accounts + +Migration `1740000009` adds `is_admin` (bool) on **`usersapp`**. In PocketBase Admin → **Users** → edit a user → enable **is_admin**. + +Admins see an **Admin** badge on Account and can **Create test transaction** (saved locally and synced with bank transactions). Normal users do not get that action. Re-sign-in after flipping the flag so the app picks it up. + ### Apply migrations (typical Linux host) ```bash diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 8477881..768b780 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -26,6 +26,13 @@ class AuthController extends ChangeNotifier { RecordModel? get user => _client?.authStore.record; + /// True when PocketBase `usersapp.is_admin` is set for this account. + bool get isAdmin { + final record = user; + if (record == null) return false; + return record.getBoolValue('is_admin'); + } + String? get email { final value = user?.getStringValue('email'); return value == null || value.isEmpty ? null : value; @@ -80,6 +87,17 @@ class AuthController extends ChangeNotifier { notifyListeners(); } + /// Refresh the auth record (e.g. after `is_admin` was toggled in Admin UI). + Future refreshSession() async { + if (!isLoggedIn) return; + try { + await client.collection(kPocketBaseUsersCollection).authRefresh(); + } catch (error, stackTrace) { + debugPrint('AuthController.refreshSession failed: $error\n$stackTrace'); + } + notifyListeners(); + } + Future logout() async { client.authStore.clear(); notifyListeners(); diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index 87067fa..7508b3b 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -161,6 +161,40 @@ class BankController extends ChangeNotifier { notifyListeners(); } + /// Insert a locally created transaction (admin testing). Persisted like CSV + /// imports and included in PocketBase `transactions` sync. + Future addManualTransaction({ + required DateTime date, + required String description, + required double amount, + String currency = 'EUR', + bool pending = false, + }) async { + final desc = description.trim().isEmpty ? 'Test transaction' : description.trim(); + // Include a short unique suffix so identical drafts do not collide on id. + final stamp = DateTime.now().millisecondsSinceEpoch % 100000; + final tx = BankTransaction( + date: + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}', + description: '$desc ·#$stamp', + amount: amount, + currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(), + pending: pending, + ); + + _importedTransactions = mergeTransactions(_importedTransactions, [tx]); + _importedTransactions = filterNewTransactions( + _remoteTransactions, + _importedTransactions, + ); + _invalidateMerged(); + await _persistImported(); + notifyListeners(); + return tx; + } + /// Union cloud txs into the local bank cache without dropping either side. /// /// Rows land in the API cache; CSV imports that match are deduped away. diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 4961bb3..7277ad6 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -1,6 +1,8 @@ /// Signed-in account details, sync status, bank link, and sign-out. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -10,11 +12,26 @@ import '../providers/sync_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/connect_bank.dart'; import '../utils/constants.dart'; +import '../widgets/admin_create_transaction_dialog.dart'; import '../widgets/responsive_body.dart'; -class AccountScreen extends StatelessWidget { +class AccountScreen extends StatefulWidget { const AccountScreen({super.key}); + @override + State createState() => _AccountScreenState(); +} + +class _AccountScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(context.read().refreshSession()); + }); + } + @override Widget build(BuildContext context) { final auth = context.watch(); @@ -62,6 +79,24 @@ class AccountScreen extends StatelessWidget { ), ), ), + if (auth.isAdmin) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Chip( + avatar: Icon( + Icons.shield_outlined, + size: 16, + color: colors.onSecondaryContainer, + ), + label: const Text('Admin'), + visualDensity: VisualDensity.compact, + backgroundColor: colors.secondaryContainer, + labelStyle: theme.textTheme.labelMedium?.copyWith( + color: colors.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), const SizedBox(height: 24), Card( child: Column( @@ -90,6 +125,36 @@ class AccountScreen extends StatelessWidget { ], ), ), + if (auth.isAdmin) ...[ + const SizedBox(height: 16), + Card( + child: ListTile( + leading: const Icon(Icons.add_card_outlined), + title: const Text('Create test transaction'), + subtitle: const Text( + 'Admin only · local + syncs to PocketBase', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final tx = await showAdminCreateTransactionDialog(context); + if (tx == null || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Created “${tx.description}” · ' + '${tx.amount.toStringAsFixed(2)} ${tx.currency}', + ), + ), + ); + // Push so other devices / PB see it soon. + final sync = context.read(); + if (sync.canSync && !sync.isSyncing) { + unawaited(sync.syncNow()); + } + }, + ), + ), + ], const SizedBox(height: 16), Card( child: ListTile( diff --git a/lib/widgets/admin_create_transaction_dialog.dart b/lib/widgets/admin_create_transaction_dialog.dart new file mode 100644 index 0000000..6fb52f8 --- /dev/null +++ b/lib/widgets/admin_create_transaction_dialog.dart @@ -0,0 +1,226 @@ +/// Admin-only dialog to create a local test bank transaction. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../models/bank.dart'; +import '../models/transaction_meta.dart'; +import '../providers/bank_provider.dart'; +import '../utils/helpers.dart'; + +Future showAdminCreateTransactionDialog( + BuildContext context, +) { + return showDialog( + context: context, + builder: (context) => const _AdminCreateTransactionDialog(), + ); +} + +class _AdminCreateTransactionDialog extends StatefulWidget { + const _AdminCreateTransactionDialog(); + + @override + State<_AdminCreateTransactionDialog> createState() => + _AdminCreateTransactionDialogState(); +} + +class _AdminCreateTransactionDialogState + extends State<_AdminCreateTransactionDialog> { + late DateTime _date; + late final TextEditingController _description; + late final TextEditingController _amount; + late final TextEditingController _currency; + var _flow = TxFlowFilter.expense; + var _pending = false; + var _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _date = DateTime(now.year, now.month, now.day); + _description = TextEditingController(text: 'Test merchant'); + _amount = TextEditingController(text: '12.50'); + _currency = TextEditingController(text: 'EUR'); + } + + @override + void dispose() { + _description.dispose(); + _amount.dispose(); + _currency.dispose(); + super.dispose(); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2015), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked == null || !mounted) return; + setState(() => _date = DateTime(picked.year, picked.month, picked.day)); + } + + Future _save() async { + final raw = _amount.text.trim().replaceAll(',', '.'); + final parsed = double.tryParse(raw); + if (parsed == null || parsed <= 0) { + setState(() => _error = 'Enter a positive amount'); + return; + } + final signed = _flow == TxFlowFilter.income ? parsed : -parsed; + setState(() { + _saving = true; + _error = null; + }); + try { + final bank = context.read(); + final tx = await bank.addManualTransaction( + date: _date, + description: _description.text, + amount: signed, + currency: _currency.text, + pending: _pending, + ); + if (!mounted) return; + Navigator.pop(context, tx); + } catch (error) { + if (!mounted) return; + setState(() { + _saving = false; + _error = '$error'; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Create test transaction'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Saved on this device and included in the next PocketBase sync.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Date'), + subtitle: Text(formatDate(_date)), + trailing: const Icon(Icons.calendar_today_outlined, size: 20), + onTap: _saving ? null : _pickDate, + ), + TextField( + controller: _description, + enabled: !_saving, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Description', + ), + ), + const SizedBox(height: 12), + SegmentedButton( + segments: const [ + ButtonSegment( + value: TxFlowFilter.expense, + label: Text('Expense'), + icon: Icon(Icons.north_east, size: 16), + ), + ButtonSegment( + value: TxFlowFilter.income, + label: Text('Income'), + icon: Icon(Icons.south_west, size: 16), + ), + ], + selected: {_flow}, + onSelectionChanged: _saving + ? null + : (selection) { + setState(() => _flow = selection.first); + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: _amount, + enabled: !_saving, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')), + ], + decoration: const InputDecoration( + labelText: 'Amount', + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _currency, + enabled: !_saving, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + labelText: 'Currency', + ), + ), + ), + ], + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Pending'), + value: _pending, + onChanged: _saving + ? null + : (value) => setState(() => _pending = value), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ], + ); + } +} diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index 9d6b17a..aeb268d 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -498,24 +498,40 @@ class _BudgetBody extends StatelessWidget { ), ], ), - HomeWidgetChartStyle.pie || HomeWidgetChartStyle.ring => Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - capLine, - const SizedBox(height: 8), - spentLine, - ], - ), + HomeWidgetChartStyle.pie || HomeWidgetChartStyle.ring => half + // Stack on half cards so amounts never get crushed beside the ring + // (and avoid LayoutBuilder — half rows use IntrinsicHeight). + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + capLine, + const SizedBox(height: 6), + spentLine, + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: ringChart(size: 56), + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + capLine, + const SizedBox(height: 6), + spentLine, + ], + ), + ), + const SizedBox(width: 8), + ringChart(size: 68), + ], ), - const SizedBox(width: 10), - ringChart(size: 68), - ], - ), }, ], ); @@ -590,8 +606,9 @@ class _BudgetLegendLine extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + // Stretch to the parent width so Expanded+ellipsis can shrink safely + // (half-width ring rows can be very tight). return Row( - mainAxisSize: MainAxisSize.min, children: [ Container( width: 7, @@ -601,11 +618,12 @@ class _BudgetLegendLine extends StatelessWidget { shape: BoxShape.circle, ), ), - const SizedBox(width: 8), - Flexible( + const SizedBox(width: 6), + Expanded( child: Text( text, maxLines: 1, + softWrap: false, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelMedium?.copyWith( color: textColor, diff --git a/pocketbase/pb_migrations/1740000009_ingtrak_usersapp_is_admin.js b/pocketbase/pb_migrations/1740000009_ingtrak_usersapp_is_admin.js new file mode 100644 index 0000000..a3733f4 --- /dev/null +++ b/pocketbase/pb_migrations/1740000009_ingtrak_usersapp_is_admin.js @@ -0,0 +1,31 @@ +/// PocketBase migration: `is_admin` on usersapp (admin vs normal accounts). +/// +/// Admins can create test transactions in the app. Set the flag in Admin UI: +/// Users → pick user → is_admin = true. +/// +/// Apply on the server: +/// +/// sudo cp this_file /opt/pocketbase/pb_migrations/ +/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js +/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up +/// sudo systemctl restart pocketbase +migrate((app) => { + const users = app.findCollectionByNameOrId("usersapp"); + if (users.fields.getByName("is_admin")) return; + + users.fields.add(new Field({ + type: "bool", + name: "is_admin", + required: false, + })); + app.save(users); +}, (app) => { + try { + const users = app.findCollectionByNameOrId("usersapp"); + const field = users.fields.getByName("is_admin"); + if (field) { + users.fields.removeById(field.id); + app.save(users); + } + } catch (_) {} +});