custom filters
This commit is contained in:
+300
-90
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user