custom filters

This commit is contained in:
davmar
2026-09-10 17:23:07 +02:00
parent 83295439d5
commit 2727f1baf2
15 changed files with 757 additions and 232 deletions
+300 -90
View File
@@ -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<HomeScreen> {
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<void>(
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<PreferencesController>();
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<void>(
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<bool> 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<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) {
final rows = <Widget>[];
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;
}