diff --git a/lib/models/home_built_in_sections.dart b/lib/models/home_built_in_sections.dart new file mode 100644 index 0000000..29ff430 --- /dev/null +++ b/lib/models/home_built_in_sections.dart @@ -0,0 +1,43 @@ +/// Visibility flags for built-in Home screen sections. +library; + +class HomeBuiltInSections { + const HomeBuiltInSections({ + this.showTransactions = true, + this.showCategorized = true, + this.showRecent = true, + }); + + static const defaults = HomeBuiltInSections(); + + final bool showTransactions; + final bool showCategorized; + final bool showRecent; + + HomeBuiltInSections copyWith({ + bool? showTransactions, + bool? showCategorized, + bool? showRecent, + }) { + return HomeBuiltInSections( + showTransactions: showTransactions ?? this.showTransactions, + showCategorized: showCategorized ?? this.showCategorized, + showRecent: showRecent ?? this.showRecent, + ); + } + + Map toJson() => { + 'showTransactions': showTransactions, + 'showCategorized': showCategorized, + 'showRecent': showRecent, + }; + + factory HomeBuiltInSections.fromJson(Map? json) { + if (json == null) return defaults; + return HomeBuiltInSections( + showTransactions: json['showTransactions'] as bool? ?? true, + showCategorized: json['showCategorized'] as bool? ?? true, + showRecent: json['showRecent'] as bool? ?? true, + ); + } +} diff --git a/lib/models/home_filter_widget.dart b/lib/models/home_filter_widget.dart index 86bb9f9..a357ceb 100644 --- a/lib/models/home_filter_widget.dart +++ b/lib/models/home_filter_widget.dart @@ -11,6 +11,7 @@ class HomeFilterWidget { required this.name, this.display = true, this.sortOrder = 0, + this.halfWidth = false, this.labelIds = const [], this.flow = TxFlowFilter.all, this.amountRange = const TxAmountRange(), @@ -26,6 +27,9 @@ class HomeFilterWidget { final bool display; final int sortOrder; + /// When true, the Home card uses half the row width. + final bool halfWidth; + final List labelIds; final TxFlowFilter flow; final TxAmountRange amountRange; @@ -49,6 +53,7 @@ class HomeFilterWidget { String? name, bool? display, int? sortOrder, + bool? halfWidth, List? labelIds, TxFlowFilter? flow, TxAmountRange? amountRange, @@ -63,6 +68,7 @@ class HomeFilterWidget { name: name ?? this.name, display: display ?? this.display, sortOrder: sortOrder ?? this.sortOrder, + halfWidth: halfWidth ?? this.halfWidth, labelIds: labelIds ?? this.labelIds, flow: flow ?? this.flow, amountRange: amountRange ?? this.amountRange, @@ -103,6 +109,7 @@ class HomeFilterWidget { 'name': name, 'display': display, 'sortOrder': sortOrder, + 'halfWidth': halfWidth, 'labelIds': labelIds, 'flow': flow.name, 'amountRange': { @@ -135,6 +142,7 @@ class HomeFilterWidget { : 'Filter', display: json['display'] as bool? ?? true, sortOrder: json['sortOrder'] as int? ?? 0, + halfWidth: json['halfWidth'] as bool? ?? false, labelIds: [ for (final id in (json['labelIds'] as List? ?? const [])) if (id is String && id.isNotEmpty) id, diff --git a/lib/providers/preferences_provider.dart b/lib/providers/preferences_provider.dart index 4ddd268..afd03d7 100644 --- a/lib/providers/preferences_provider.dart +++ b/lib/providers/preferences_provider.dart @@ -3,6 +3,7 @@ library; import 'package:flutter/foundation.dart'; +import '../models/home_built_in_sections.dart'; import '../models/home_filter_widget.dart'; import '../models/overview_favourite.dart'; import '../services/storage_service.dart'; @@ -12,12 +13,14 @@ class PreferencesController extends ChangeNotifier { _lookAheadMaxYears = _storage.lookAheadMaxYears; _overviewFavourites = _storage.loadOverviewFavourites(); _homeFilterWidgets = _storage.loadHomeFilterWidgets(); + _homeBuiltInSections = _storage.loadHomeBuiltInSections(); } final StorageService _storage; late int _lookAheadMaxYears; late List _overviewFavourites; late List _homeFilterWidgets; + late HomeBuiltInSections _homeBuiltInSections; /// Fired after favourites are persisted (for cloud push debounce). VoidCallback? onFavouritesPersisted; @@ -50,6 +53,31 @@ class PreferencesController extends ChangeNotifier { bool get hasDisplayedHomeFilterWidgets => _homeFilterWidgets.any((w) => w.display); + HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections; + + Future setHomeBuiltInSections(HomeBuiltInSections sections) async { + if (sections.showTransactions == _homeBuiltInSections.showTransactions && + sections.showCategorized == _homeBuiltInSections.showCategorized && + sections.showRecent == _homeBuiltInSections.showRecent) { + return; + } + _homeBuiltInSections = sections; + notifyListeners(); + await _storage.saveHomeBuiltInSections(sections); + } + + Future setHomeShowTransactions(bool value) => setHomeBuiltInSections( + _homeBuiltInSections.copyWith(showTransactions: value), + ); + + Future setHomeShowCategorized(bool value) => setHomeBuiltInSections( + _homeBuiltInSections.copyWith(showCategorized: value), + ); + + Future setHomeShowRecent(bool value) => setHomeBuiltInSections( + _homeBuiltInSections.copyWith(showRecent: value), + ); + Future setLookAheadMaxYears(int years) async { final next = years.clamp(1, 30); if (next == _lookAheadMaxYears) return; @@ -149,6 +177,19 @@ class PreferencesController extends ChangeNotifier { await updateHomeFilterWidget(current.copyWith(display: display)); } + Future reorderHomeFilterWidgets(int oldIndex, int newIndex) async { + if (oldIndex == newIndex) return; + final next = [..._homeFilterWidgets]; + final item = next.removeAt(oldIndex); + final target = newIndex.clamp(0, next.length); + next.insert(target, item); + _homeFilterWidgets = [ + for (var i = 0; i < next.length; i++) next[i].copyWith(sortOrder: i), + ]; + notifyListeners(); + await _persistHomeWidgets(); + } + Future replaceHomeFilterWidgets( List widgets, { bool notify = true, diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 04fbfad..1934149 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -39,9 +39,9 @@ class SyncController extends ChangeNotifier { _bank = bank, _prefs = prefs { _wasLoggedIn = auth.isLoggedIn; - _meta.onPersisted = _schedulePush; - _prefs.onFavouritesPersisted = _schedulePush; - _prefs.onHomeWidgetsPersisted = _schedulePush; + _meta.onPersisted = _scheduleMetaPush; + _prefs.onFavouritesPersisted = _scheduleFavouritesPush; + _prefs.onHomeWidgetsPersisted = _scheduleHomeWidgetsPush; _auth.addListener(_onAuthChanged); _bank.onAfterSuccessfulLoad = _onBankDataLoaded; if (_auth.isLoggedIn) { @@ -58,6 +58,9 @@ class SyncController extends ChangeNotifier { bool _syncing = false; bool _pushQueued = false; bool _queuedFullSync = false; + bool _pushMetaPending = false; + bool _pushFavouritesPending = false; + bool _pushHomeWidgetsPending = false; Timer? _pushDebounce; Timer? _progressHold; Timer? _uiNotifyThrottle; @@ -187,6 +190,10 @@ class SyncController extends ChangeNotifier { _progressHold?.cancel(); _uiNotifyThrottle?.cancel(); _uiNotifyQueued = false; + _pushQueued = false; + _pushMetaPending = false; + _pushFavouritesPending = false; + _pushHomeWidgetsPending = false; _lastError = null; _statusDetail = ''; _showProgressRing = false; @@ -224,8 +231,27 @@ class SyncController extends ChangeNotifier { } } - void _schedulePush() { + void _scheduleMetaPush() { + _pushMetaPending = true; + _armPushDebounce(); + } + + void _scheduleFavouritesPush() { + _pushFavouritesPending = true; + _armPushDebounce(); + } + + void _scheduleHomeWidgetsPush() { + _pushHomeWidgetsPending = true; + _armPushDebounce(); + } + + void _armPushDebounce() { if (!_auth.isLoggedIn) return; + if (_syncing) { + _pushQueued = true; + return; + } _pushDebounce?.cancel(); _pushDebounce = Timer(const Duration(seconds: 2), () { unawaited(_pushOnly()); @@ -370,53 +396,100 @@ class SyncController extends ChangeNotifier { } } - /// Annotation-only push after local meta edits (avoids re-uploading 4k+ txs). + /// Pushes only the stores that changed locally (debounced). + /// + /// Annotation edits push tx_meta; favourite / Home-widget edits do not + /// re-upload the full annotation store or bank transactions. Future _pushOnly() async { - if (!_auth.isLoggedIn || _syncing) { - if (_syncing) _pushQueued = true; + if (!_auth.isLoggedIn) return; + if (_syncing) { + _pushQueued = true; return; } + final pushMeta = _pushMetaPending; + final pushFavourites = _pushFavouritesPending; + final pushHomeWidgets = _pushHomeWidgetsPending; + _pushMetaPending = false; + _pushFavouritesPending = false; + _pushHomeWidgetsPending = false; + if (!pushMeta && !pushFavourites && !pushHomeWidgets) return; + _progressHold?.cancel(); _syncing = true; _progressSuccess = false; - _statusDetail = 'Pushing…'; + + final label = switch ((pushMeta, pushFavourites, pushHomeWidgets)) { + (false, false, true) => 'Home widgets', + (false, true, false) => 'Favourites', + (false, true, true) => 'Home layout', + (true, false, false) => 'Annotations', + _ => 'Changes', + }; + _statusDetail = 'Pushing $label…'; _setProgress(0.05); - _announce('Syncing…', isProgress: true); + _announce('Saving $label…', isProgress: true); + try { final service = PbSyncService(_auth.client); - final summary = await service.push( - _meta.exportStore(), - progressStart: 0.05, - progressEnd: 0.9, - onProgress: _setProgress, - onStatus: _setStatus, - ); - try { - await service.pushOverviewFavourites( - _prefs.overviewFavourites, + final parts = []; + var done = 0; + final total = (pushMeta ? 1 : 0) + + (pushFavourites ? 1 : 0) + + (pushHomeWidgets ? 1 : 0); + void bump() { + done++; + _setProgress(done / total); + } + + if (pushMeta) { + _setStatus('Pushing annotations…'); + final summary = await service.push( + _meta.exportStore(), + progressStart: done / total, + progressEnd: (done + 1) / total, + onProgress: _setProgress, onStatus: _setStatus, ); - } catch (error) { - debugPrint( - 'SyncController: overview_favourites push skipped ($error)', - ); + parts.add(summary); + bump(); } - try { - await service.pushHomeFilterWidgets( - _prefs.homeFilterWidgets, - onStatus: _setStatus, - ); - } catch (error) { - debugPrint( - 'SyncController: home_filter_widgets push skipped ($error)', - ); + if (pushFavourites) { + _setStatus('Pushing favourites…'); + try { + await service.pushOverviewFavourites( + _prefs.overviewFavourites, + onStatus: _setStatus, + ); + parts.add('Favourites updated'); + } catch (error) { + debugPrint( + 'SyncController: overview_favourites push skipped ($error)', + ); + } + bump(); } + if (pushHomeWidgets) { + _setStatus('Pushing home widgets…'); + try { + await service.pushHomeFilterWidgets( + _prefs.homeFilterWidgets, + onStatus: _setStatus, + ); + parts.add('Home widgets updated'); + } catch (error) { + debugPrint( + 'SyncController: home_filter_widgets push skipped ($error)', + ); + } + bump(); + } + _setProgress(1.0); _lastSyncedAt = DateTime.now(); _lastError = null; - _lastSyncSummary = summary; - _announce('Sync complete'); + _lastSyncSummary = parts.isEmpty ? label : parts.join('\n'); + _announce('Saved $label'); _syncing = false; _finishProgress(success: true); } catch (error, stackTrace) { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index dcc23fa..9b20408 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/home_filter_widget.dart'; import '../providers/bank_provider.dart'; import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; @@ -45,11 +46,11 @@ class _HomeScreenState extends State { return Scaffold( appBar: AppBar( - title: Text(_editingWidgets ? 'Edit widgets' : 'Home'), + title: Text(_editingWidgets ? 'Edit Home' : 'Home'), actions: [ if (bank.isLinked || bank.hasData) IconButton( - tooltip: _editingWidgets ? 'Done' : 'Edit Home widgets', + tooltip: _editingWidgets ? 'Done' : 'Edit Home layout', onPressed: () => setState(() => _editingWidgets = !_editingWidgets), icon: Icon( @@ -218,41 +219,74 @@ class _HomeBody extends StatelessWidget { ), ), ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: _StatChip( - label: 'Transactions', - value: '${bank.transactions.length}', - icon: Icons.list_alt, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _StatChip( - label: 'Categorized', - value: '$categorized', - icon: Icons.label_outline, - ), - ), - ], - ), - if (editingWidgets || widgets.isNotEmpty) ...[ - const SizedBox(height: 24), + if (_showBuiltIn( + editing: editingWidgets, + enabled: prefs.homeBuiltInSections.showTransactions, + ) || + _showBuiltIn( + editing: editingWidgets, + enabled: prefs.homeBuiltInSections.showCategorized, + )) ...[ + const SizedBox(height: 20), Row( children: [ - Text('Widgets', style: theme.textTheme.titleMedium), - const Spacer(), - if (editingWidgets) - TextButton.icon( - onPressed: () => showHomeWidgetsSheet(context), - icon: const Icon(Icons.add, size: 18), - label: const Text('Add'), + if (_showBuiltIn( + editing: editingWidgets, + enabled: prefs.homeBuiltInSections.showTransactions, + )) + Expanded( + child: _EditableHomeBlock( + editing: editingWidgets, + visible: prefs.homeBuiltInSections.showTransactions, + onToggleVisible: (value) => + prefs.setHomeShowTransactions(value), + child: _StatChip( + label: 'Transactions', + value: '${bank.transactions.length}', + icon: Icons.list_alt, + ), + ), + ), + if (_showBuiltIn( + editing: editingWidgets, + enabled: prefs.homeBuiltInSections.showTransactions, + ) && + _showBuiltIn( + editing: editingWidgets, + enabled: prefs.homeBuiltInSections.showCategorized, + )) + const SizedBox(width: 10), + if (_showBuiltIn( + editing: editingWidgets, + enabled: prefs.homeBuiltInSections.showCategorized, + )) + Expanded( + child: _EditableHomeBlock( + editing: editingWidgets, + visible: prefs.homeBuiltInSections.showCategorized, + onToggleVisible: (value) => + prefs.setHomeShowCategorized(value), + child: _StatChip( + label: 'Categorized', + value: '$categorized', + icon: Icons.label_outline, + ), + ), ), ], ), - const SizedBox(height: 8), + ], + if (editingWidgets || widgets.isNotEmpty) ...[ + const SizedBox(height: 24), + if (editingWidgets) + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => showHomeWidgetsSheet(context), + icon: const Icon(Icons.add, size: 18), + label: const Text('Add'), + ), + ), if (widgets.isEmpty) Text( 'No widgets yet. Tap Add to pin a transaction filter.', @@ -260,68 +294,137 @@ class _HomeBody extends StatelessWidget { color: colors.onSurfaceVariant, ), ) - else - for (final widgetConfig in widgets) ...[ - HomeFilterWidgetCard( - widgetConfig: widgetConfig, - editing: editingWidgets, - onRemove: editingWidgets - ? () => prefs.removeHomeFilterWidget(widgetConfig.id) - : null, - ), - const SizedBox(height: 10), - ], - ], - if (recent.isNotEmpty) ...[ - const SizedBox(height: 24), - Row( - children: [ - Text('Recent', style: theme.textTheme.titleMedium), - const Spacer(), - TextButton( - onPressed: () => _openTransactions(context), - child: const Text('See all'), - ), - ], - ), - const SizedBox(height: 4), - for (final tx in recent) - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: Builder( - builder: (context) { - final labels = - meta.leafLabelsFor(meta.metaFor(tx)); - final label = labels.isEmpty ? null : labels.first; - return Icon( - label?.icon ?? Icons.receipt_long_outlined, - color: label?.color, - ); - }, - ), - title: Text( - tx.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text(formatIsoDate(tx.date)), - trailing: Text( - formatMoney(tx.amount, tx.currency), - style: theme.textTheme.titleSmall?.copyWith( - color: tx.amount >= 0 - ? AppTheme.accent - : AppTheme.expense, - ), - ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TransactionDetailScreen(tx: tx), + else if (editingWidgets) + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: widgets.length, + onReorderItem: prefs.reorderHomeFilterWidgets, + proxyDecorator: (child, index, animation) { + return Material( + elevation: 2, + color: Colors.transparent, + borderRadius: BorderRadius.circular(16), + child: child, + ); + }, + itemBuilder: (context, index) { + final widgetConfig = widgets[index]; + return Padding( + key: ValueKey(widgetConfig.id), + padding: const EdgeInsets.only(bottom: 10), + child: _HomeWidgetSlot( + halfWidth: widgetConfig.halfWidth, + child: HomeFilterWidgetCard( + widgetConfig: widgetConfig, + editing: true, + onToggleDisplay: (display) => + prefs.setHomeFilterWidgetDisplay( + widgetConfig.id, + display, + ), + onEdit: () async { + final preferences = + context.read(); + final updated = await composeHomeFilterWidget( + context, + existing: widgetConfig, + ); + if (updated == null) return; + await preferences.updateHomeFilterWidget(updated); + }, + dragHandle: ReorderableDragStartListener( + index: index, + child: Icon( + Icons.drag_handle, + color: colors.onSurfaceVariant, + ), + ), + ), ), ); }, + ) + else + ..._packedHomeWidgetRows(widgets), + ], + if (_showBuiltIn( + editing: editingWidgets, + enabled: prefs.homeBuiltInSections.showRecent, + ) && + (recent.isNotEmpty || editingWidgets)) ...[ + const SizedBox(height: 24), + _EditableHomeBlock( + editing: editingWidgets, + visible: prefs.homeBuiltInSections.showRecent, + onToggleVisible: (value) => prefs.setHomeShowRecent(value), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text('Recent', style: theme.textTheme.titleMedium), + const Spacer(), + TextButton( + onPressed: () => _openTransactions(context), + child: const Text('See all'), + ), + ], + ), + const SizedBox(height: 4), + if (recent.isEmpty) + Text( + 'No recent transactions yet.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ) + else + for (final tx in recent) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Builder( + builder: (context) { + final labels = + meta.leafLabelsFor(meta.metaFor(tx)); + final label = + labels.isEmpty ? null : labels.first; + return Icon( + label?.icon ?? Icons.receipt_long_outlined, + color: label?.color, + ); + }, + ), + title: Text( + tx.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatIsoDate(tx.date)), + trailing: Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.titleSmall?.copyWith( + color: tx.amount >= 0 + ? AppTheme.accent + : AppTheme.expense, + ), + ), + onTap: editingWidgets + ? null + : () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + TransactionDetailScreen(tx: tx), + ), + ); + }, + ), + ], ), + ), ], if (bank.error != null) ...[ const SizedBox(height: 12), @@ -382,3 +485,110 @@ class _StatChip extends StatelessWidget { ); } } + +bool _showBuiltIn({required bool editing, required bool enabled}) => + editing || enabled; + +/// Wraps a built-in Home block with show/hide controls while editing. +class _EditableHomeBlock extends StatelessWidget { + const _EditableHomeBlock({ + required this.editing, + required this.visible, + required this.onToggleVisible, + required this.child, + }); + + final bool editing; + final bool visible; + final ValueChanged onToggleVisible; + final Widget child; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final content = Opacity( + opacity: editing && !visible ? 0.45 : 1, + child: child, + ); + if (!editing) return content; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: content), + IconButton( + tooltip: visible ? 'Hide from Home' : 'Show on Home', + onPressed: () => onToggleVisible(!visible), + icon: Icon( + visible ? Icons.visibility_outlined : Icons.visibility_off_outlined, + color: visible ? colors.onSurfaceVariant : colors.primary, + ), + ), + ], + ); + } +} + +/// Full- or half-width slot for a Home filter widget card. +class _HomeWidgetSlot extends StatelessWidget { + const _HomeWidgetSlot({ + required this.halfWidth, + required this.child, + }); + + final bool halfWidth; + final Widget child; + + @override + Widget build(BuildContext context) { + if (!halfWidth) return child; + return LayoutBuilder( + builder: (context, constraints) { + final width = (constraints.maxWidth - 10) / 2; + return Align( + alignment: Alignment.centerLeft, + child: SizedBox(width: width, child: child), + ); + }, + ); + } +} + +List _packedHomeWidgetRows(List widgets) { + final rows = []; + var i = 0; + while (i < widgets.length) { + final a = widgets[i]; + final canPair = a.halfWidth && + i + 1 < widgets.length && + widgets[i + 1].halfWidth; + if (canPair) { + final b = widgets[i + 1]; + rows.add( + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: HomeFilterWidgetCard(widgetConfig: a)), + const SizedBox(width: 10), + Expanded(child: HomeFilterWidgetCard(widgetConfig: b)), + ], + ), + ), + ); + i += 2; + continue; + } + rows.add( + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _HomeWidgetSlot( + halfWidth: a.halfWidth, + child: HomeFilterWidgetCard(widgetConfig: a), + ), + ), + ); + i += 1; + } + return rows; +} diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 4fe6b67..16bd41d 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -241,6 +241,7 @@ class _TransactionsScreenState extends State { } Future _saveAsHomeWidget() async { + final preferences = context.read(); final created = await composeHomeFilterWidget( context, initialFilter: TransactionFilterSelection( @@ -252,8 +253,8 @@ class _TransactionsScreenState extends State { ), skipFilterDialog: true, ); - if (created == null || !mounted) return; - await context.read().addHomeFilterWidget(created); + if (created == null) return; + await preferences.addHomeFilterWidget(created); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Added “${created.name}” to Home')), diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index 576caab..a148e1f 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -520,6 +520,7 @@ class PbSyncService { 'name': r.getStringValue('name'), 'display': r.getBoolValue('display', true), 'sortOrder': r.getIntValue('sort_order', 0), + 'halfWidth': r.getBoolValue('half_width'), 'labelIds': _jsonStringList(r.get('label_ids')), 'flow': r.getStringValue('flow'), 'amountRange': rangeRaw is Map @@ -1197,6 +1198,7 @@ class PbSyncService { 'name': _clip(w.name.isEmpty ? 'Filter' : w.name, 200), 'display': w.display, 'sort_order': w.sortOrder, + 'half_width': w.halfWidth, 'label_ids': w.labelIds, 'flow': w.flow.name, 'amount_range': { @@ -1297,6 +1299,7 @@ class PbSyncService { return remote.getStringValue('name') == w.name && remote.getBoolValue('display', true) == w.display && remote.getIntValue('sort_order', 0) == w.sortOrder && + remote.getBoolValue('half_width') == w.halfWidth && _sameIdList(remoteIds, w.labelIds) && remote.getStringValue('flow') == w.flow.name && asDouble(rangeMap['exact']) == w.amountRange.exact && diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 0d55505..a4d0f8b 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/bank.dart'; +import '../models/home_built_in_sections.dart'; import '../models/home_filter_widget.dart'; import '../models/overview_favourite.dart'; import '../models/transaction_meta.dart'; @@ -183,6 +184,27 @@ class StorageService { return _prefs.setString(StorageKeys.homeFilterWidgets, encoded); } + HomeBuiltInSections loadHomeBuiltInSections() { + final raw = _prefs.getString(StorageKeys.homeBuiltInSections); + if (raw == null || raw.isEmpty) return HomeBuiltInSections.defaults; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return HomeBuiltInSections.defaults; + return HomeBuiltInSections.fromJson( + Map.from(decoded), + ); + } catch (_) { + return HomeBuiltInSections.defaults; + } + } + + Future saveHomeBuiltInSections(HomeBuiltInSections sections) { + return _prefs.setString( + StorageKeys.homeBuiltInSections, + jsonEncode(sections.toJson()), + ); + } + bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false; Future setUseRemoteData(bool value) { diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 7e13ea7..66fc94e 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -57,6 +57,9 @@ abstract final class StorageKeys { /// Home screen filter widgets (JSON list). static const homeFilterWidgets = 'home_filter_widgets_v1'; + + /// Built-in Home sections visibility (Transactions / Categorized / Recent). + static const homeBuiltInSections = 'home_built_in_sections_v1'; } /// Named routes registered on [MaterialApp]. diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index fc1e894..4fe6df2 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -17,14 +17,18 @@ class HomeFilterWidgetCard extends StatelessWidget { super.key, required this.widgetConfig, this.editing = false, - this.onRemove, + this.onToggleDisplay, + this.onEdit, + this.dragHandle, }); final HomeFilterWidget widgetConfig; final bool editing; - final VoidCallback? onRemove; + final ValueChanged? onToggleDisplay; + final VoidCallback? onEdit; + final Widget? dragHandle; - void _open(BuildContext context) { + void _openTransactions(BuildContext context) { final filter = widgetConfig.filter; Navigator.of(context).push( MaterialPageRoute( @@ -56,72 +60,90 @@ class HomeFilterWidgetCard extends StatelessWidget { filter: widgetConfig.filter, ); - return Card( - color: colors.surfaceContainer, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: editing ? null : () => _open(context), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widgetConfig.name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + final hidden = editing && !widgetConfig.display; + + return Opacity( + opacity: hidden ? 0.45 : 1, + child: Card( + color: colors.surfaceContainer, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: editing + ? onEdit + : () => _openTransactions(context), + child: Padding( + padding: EdgeInsets.fromLTRB( + 14, + 12, + editing ? 4 : 14, + 12, + ), + child: Row( + children: [ + if (dragHandle != null) ...[ + dragHandle!, + const SizedBox(width: 4), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widgetConfig.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 6, - children: [ - if (widgetConfig.showAmountIn) - _Metric( - label: 'In', - value: formatMoney(metrics.amountIn, currency), - color: AppTheme.accent, - ), - if (widgetConfig.showAmountOut) - _Metric( - label: 'Out', - value: formatMoney(metrics.amountOut, currency), - color: AppTheme.expense, - ), - if (widgetConfig.showCount) - _Metric( - label: 'Txs', - value: '${metrics.count}', - color: colors.primary, - ), - ], - ), - ], - ), - ), - if (editing) - IconButton( - tooltip: 'Remove from Home', - onPressed: onRemove, - icon: Icon( - Icons.remove_circle_outline, - color: colors.error, + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + if (widgetConfig.showAmountIn) + _Metric( + label: 'In', + value: formatMoney(metrics.amountIn, currency), + color: AppTheme.accent, + ), + if (widgetConfig.showAmountOut) + _Metric( + label: 'Out', + value: formatMoney(metrics.amountOut, currency), + color: AppTheme.expense, + ), + if (widgetConfig.showCount) + _Metric( + label: 'Txs', + value: '${metrics.count}', + color: colors.primary, + ), + ], + ), + ], ), - ) - else - Icon( - Icons.chevron_right, - color: colors.onSurfaceVariant, ), - ], + if (editing && onToggleDisplay != null) + IconButton( + tooltip: widgetConfig.display + ? 'Hide from Home' + : 'Show on Home', + onPressed: () => + onToggleDisplay!(!widgetConfig.display), + icon: Icon( + widgetConfig.display + ? Icons.remove_circle_outline + : Icons.add_circle_outline, + color: widgetConfig.display + ? colors.error + : colors.primary, + ), + ), + ], + ), ), ), ), diff --git a/lib/widgets/home_widgets_sheet.dart b/lib/widgets/home_widgets_sheet.dart index 6e783eb..1b6e892 100644 --- a/lib/widgets/home_widgets_sheet.dart +++ b/lib/widgets/home_widgets_sheet.dart @@ -34,28 +34,33 @@ Future composeHomeFilterWidget( HomeFilterWidget? existing, bool skipFilterDialog = false, }) async { + // Capture before any modal opens — the caller's element may be rebuilt. + final bank = context.read(); + final meta = context.read(); + final navigator = Navigator.of(context); + TransactionFilterSelection? filter = initialFilter ?? existing?.filter; if (!skipFilterDialog || filter == null) { - final bank = context.read(); - final meta = context.read(); filter = await showTransactionFilterDialog( - context, + navigator.context, available: meta.allLabels, transactions: bank.transactions, initial: filter ?? const TransactionFilterSelection(labelIds: {}), ); - if (filter == null || !context.mounted) return null; + if (filter == null || !navigator.mounted) return null; } + if (!navigator.mounted) return null; final name = await promptFavouriteName( - context, + navigator.context, initial: existing?.name ?? '', title: existing == null ? 'Name this widget' : 'Rename widget', ); - if (name == null || !context.mounted) return null; + if (name == null || !navigator.mounted) return null; - await Future.delayed(Duration.zero); - if (!context.mounted) return null; + // Let the name dialog finish popping before presenting options. + await WidgetsBinding.instance.endOfFrame; + if (!navigator.mounted) return null; final draft = existing == null ? HomeFilterWidget.fromFilter(name: name, filter: filter) @@ -68,7 +73,7 @@ Future composeHomeFilterWidget( monthOnly: filter.monthOnly, ); - return _promptWidgetMetrics(context, draft); + return _promptWidgetMetrics(navigator.context, draft); } Future _promptWidgetMetrics( @@ -78,6 +83,7 @@ Future _promptWidgetMetrics( var showIn = draft.showAmountIn; var showOut = draft.showAmountOut; var showCount = draft.showCount; + var halfWidth = draft.halfWidth; return showDialog( context: context, @@ -86,7 +92,7 @@ Future _promptWidgetMetrics( return StatefulBuilder( builder: (context, setState) { return AlertDialog( - title: const Text('Widget metrics'), + title: const Text('Widget options'), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -111,6 +117,13 @@ Future _promptWidgetMetrics( onChanged: (value) => setState(() => showCount = value ?? false), ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Half width'), + subtitle: const Text('Share the row with another widget'), + value: halfWidth, + onChanged: (value) => setState(() => halfWidth = value), + ), ], ), actions: [ @@ -126,6 +139,7 @@ Future _promptWidgetMetrics( showAmountIn: showIn, showAmountOut: showOut, showCount: showCount, + halfWidth: halfWidth, ), ); }, @@ -161,11 +175,11 @@ class _HomeWidgetsSheet extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Home widgets', style: theme.textTheme.titleLarge), + Text('Home layout', style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text( - 'Pin saved transaction filters on Home. Check Display to ' - 'show a widget; edit metrics or remove anytime.', + 'Choose which built-in sections and filter widgets appear ' + 'on Home. Check Display to show an item.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -174,12 +188,51 @@ class _HomeWidgetsSheet extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), + child: Column( + children: [ + CheckboxListTile( + value: prefs.homeBuiltInSections.showTransactions, + onChanged: (value) { + if (value == null) return; + prefs.setHomeShowTransactions(value); + }, + title: const Text('Transactions'), + subtitle: const Text('Total transaction count'), + controlAffinity: ListTileControlAffinity.leading, + ), + CheckboxListTile( + value: prefs.homeBuiltInSections.showCategorized, + onChanged: (value) { + if (value == null) return; + prefs.setHomeShowCategorized(value); + }, + title: const Text('Categorized'), + subtitle: const Text('Labeled transaction count'), + controlAffinity: ListTileControlAffinity.leading, + ), + CheckboxListTile( + value: prefs.homeBuiltInSections.showRecent, + onChanged: (value) { + if (value == null) return; + prefs.setHomeShowRecent(value); + }, + title: const Text('Recent transactions'), + subtitle: const Text('Latest activity list'), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: OutlinedButton.icon( onPressed: () async { + final preferences = context.read(); final created = await composeHomeFilterWidget(context); - if (created == null || !context.mounted) return; - await prefs.addHomeFilterWidget(created); + if (created == null) return; + await preferences.addHomeFilterWidget(created); }, icon: const Icon(Icons.add), label: const Text('Add filter widget'), @@ -192,8 +245,8 @@ class _HomeWidgetsSheet extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(24), child: Text( - 'No widgets yet. Add a filter, or save one from ' - 'Transactions.', + 'No filter widgets yet. Add one here, or save a ' + 'filter from Transactions.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, @@ -216,17 +269,35 @@ class _HomeWidgetsSheet extends StatelessWidget { value, ); }, - secondary: IconButton( - tooltip: 'Edit', - onPressed: () async { - final updated = await composeHomeFilterWidget( - context, - existing: widgetItem, - ); - if (updated == null || !context.mounted) return; - await prefs.updateHomeFilterWidget(updated); + secondary: PopupMenuButton( + tooltip: 'Widget actions', + onSelected: (value) async { + if (value == 'edit') { + final preferences = + context.read(); + final updated = + await composeHomeFilterWidget( + context, + existing: widgetItem, + ); + if (updated == null) return; + await preferences + .updateHomeFilterWidget(updated); + } else if (value == 'delete') { + await prefs + .removeHomeFilterWidget(widgetItem.id); + } }, - icon: const Icon(Icons.edit_outlined), + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'edit', + child: Text('Edit'), + ), + PopupMenuItem( + value: 'delete', + child: Text('Delete'), + ), + ], ), title: Text(widgetItem.name), subtitle: Text( @@ -254,6 +325,7 @@ class _HomeWidgetsSheet extends StatelessWidget { if (w.showAmountIn) 'In', if (w.showAmountOut) 'Out', if (w.showCount) 'Count', + if (w.halfWidth) 'Half', ]; return parts.isEmpty ? 'All transactions' : parts.join(' · '); } diff --git a/lib/widgets/skip_build_when_covered.dart b/lib/widgets/skip_build_when_covered.dart index 9273d6a..697fa2f 100644 --- a/lib/widgets/skip_build_when_covered.dart +++ b/lib/widgets/skip_build_when_covered.dart @@ -9,8 +9,9 @@ final RouteObserver> appRouteObserver = /// Builds via [builder] only when this route is not covered by a pushed page. /// -/// Uses [ModalRoute.isCurrent] so nested dialogs above a bottom sheet do not -/// falsely "uncover" the page when the dialog pops (sheet still open). +/// Only [PageRoute]s count as covering (via [RouteAware]). Modal bottom sheets +/// and dialogs must not tear down the subtree — async flows started from Home +/// (e.g. compose widget → name → options) need a mounted context between steps. class SkipBuildWhenCovered extends StatefulWidget { const SkipBuildWhenCovered({ super.key, @@ -29,13 +30,6 @@ class _SkipBuildWhenCoveredState extends State with RouteAware { var _covered = false; - void _syncCovered() { - final route = ModalRoute.of(context); - final covered = route != null && !route.isCurrent; - if (covered == _covered) return; - setState(() => _covered = covered); - } - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -43,7 +37,6 @@ class _SkipBuildWhenCoveredState extends State if (route is PageRoute) { appRouteObserver.subscribe(this, route); } - _syncCovered(); } @override @@ -53,10 +46,14 @@ class _SkipBuildWhenCoveredState extends State } @override - void didPushNext() => _syncCovered(); + void didPushNext() { + if (!_covered) setState(() => _covered = true); + } @override - void didPopNext() => _syncCovered(); + void didPopNext() { + if (_covered) setState(() => _covered = false); + } @override Widget build(BuildContext context) { diff --git a/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js b/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js index e891800..74ec792 100644 --- a/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js +++ b/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js @@ -57,6 +57,11 @@ migrate((app) => { required: false, onlyInt: true, }, + { + type: "bool", + name: "half_width", + required: false, + }, { type: "json", name: "label_ids", diff --git a/pocketbase/pb_migrations/1740000005_ingtrak_home_widgets_half_width.js b/pocketbase/pb_migrations/1740000005_ingtrak_home_widgets_half_width.js new file mode 100644 index 0000000..8d13bdb --- /dev/null +++ b/pocketbase/pb_migrations/1740000005_ingtrak_home_widgets_half_width.js @@ -0,0 +1,21 @@ +/// PocketBase migration: half_width on home_filter_widgets. +/// +/// Apply after 1740000004 if that collection already exists. +migrate((app) => { + const col = app.findCollectionByNameOrId("home_filter_widgets"); + col.fields.add({ + type: "bool", + name: "half_width", + required: false, + }); + app.save(col); +}, (app) => { + try { + const col = app.findCollectionByNameOrId("home_filter_widgets"); + const field = col.fields.getByName("half_width"); + if (field) { + col.fields.remove(field); + app.save(col); + } + } catch (_) {} +}); diff --git a/test/home_filter_widget_test.dart b/test/home_filter_widget_test.dart index dc1cd41..5927a99 100644 --- a/test/home_filter_widget_test.dart +++ b/test/home_filter_widget_test.dart @@ -22,6 +22,10 @@ void main() { expect(restored.showAmountOut, isTrue); expect(restored.showAmountIn, isFalse); expect(restored.showCount, isTrue); + expect(restored.halfWidth, isFalse); + + final half = widget.copyWith(halfWidth: true); + expect(HomeFilterWidget.fromJson(half.toJson()).halfWidth, isTrue); }); test('mergeHomeFilterWidgets unions by id with local wins', () {