better UI

This commit is contained in:
davmar
2026-09-09 14:38:17 +02:00
parent 903a2980de
commit ae464ff304
9 changed files with 771 additions and 471 deletions
+351 -291
View File
@@ -85,14 +85,22 @@ Future<TransactionFilterSelection?> showTransactionFilterDialog(
required TransactionFilterSelection initial,
DateTime? currentMonth,
}) {
return showDialog<TransactionFilterSelection>(
return showModalBottomSheet<TransactionFilterSelection>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (dialogContext) {
return _TransactionFilterDialog(
available: available,
transactions: transactions,
initial: initial,
currentMonth: currentMonth ?? monthStart(DateTime.now()),
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(dialogContext).bottom,
),
child: _TransactionFilterDialog(
available: available,
transactions: transactions,
initial: initial,
currentMonth: currentMonth ?? monthStart(DateTime.now()),
),
);
},
);
@@ -476,8 +484,6 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
final colors = theme.colorScheme;
final meta = context.watch<TxMetaController>();
final size = MediaQuery.sizeOf(context);
final dialogWidth =
(size.width - 48).clamp(280.0, 560.0).toDouble();
final needle = _labelSearchController.text.trim().toLowerCase();
final entries = _filterLabelEntries(
meta: meta,
@@ -488,246 +494,285 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
kUncategorizedFilterLabel.name.toLowerCase().contains(needle);
final sectionStyle = _sectionStyle(theme, colors);
return AlertDialog(
title: const Text('Filter transactions'),
contentPadding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
content: SizedBox(
width: dialogWidth,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Search, period, flow, amount, and labels.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Text('Search text', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _queryController,
textInputAction: TextInputAction.search,
decoration: const InputDecoration(
hintText: 'Description, notes, amount…',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 14),
Text('Period', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<bool>(
value: _monthOnly,
onChanged: (value) => setState(() => _monthOnly = value),
options: const [
_RectToggleOption(
value: false,
label: 'All',
),
_RectToggleOption(
value: true,
label: 'Month',
icon: Icons.calendar_month,
),
],
),
if (_monthOnly) ...[
const SizedBox(height: 8),
// Fixed sheet height so Expanded labels get a real viewport (not a tiny 220px box).
final sheetHeight = (size.height * 0.92).clamp(420.0, size.height);
return SizedBox(
height: sheetHeight,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Uses ${formatMonthYear(widget.currentMonth)}. '
'Change month on the transactions list.',
style: theme.textTheme.bodySmall?.copyWith(
'Filter transactions',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'Search, period, flow, amount, and labels.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
const SizedBox(height: 14),
Text('Income / expense', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
const SizedBox(height: 14),
Text('Amount', style: sectionStyle),
const SizedBox(height: 8),
Text(
'Exact',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
TextField(
controller: _exactAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
isDense: true,
hintText: 'e.g. 12.50',
prefixIcon: const Icon(Icons.pin, size: 20),
suffixIcon: _exactAmountController.text.trim().isEmpty
? null
: IconButton(
tooltip: 'Clear exact',
onPressed: () {
_exactAmountController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) {
setState(() {
// Exact takes precedence; clear range filter intent.
_rangeTouched = false;
_amountRange = RangeValues(0, _amountCeil);
});
},
),
const SizedBox(height: 12),
Text(
'Range',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_rangeTouched
? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}'
: 'Any amount',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
RangeSlider(
values: _amountRange,
min: 0,
max: _amountCeil,
labels: RangeLabels(
_amountRange.start.toStringAsFixed(0),
_amountRange.end.toStringAsFixed(0),
),
onChanged: (values) {
setState(() {
_exactAmountController.clear();
_rangeTouched = true;
_amountRange = values;
});
},
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: !_rangeTouched
? null
: () {
setState(() {
_rangeTouched = false;
_amountRange = RangeValues(0, _amountCeil);
});
},
child: const Text('Reset range'),
),
),
const SizedBox(height: 8),
Text('Labels', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
SizedBox(
height: 220,
width: dialogWidth,
child: _LabelChecklist(
entries: entries,
showUncategorized: showUncategorized,
selected: _selected,
onToggle: _toggle,
allowMove: false,
),
),
],
),
),
),
const SizedBox(height: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
flex: 2,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Search text', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _queryController,
textInputAction: TextInputAction.search,
decoration: const InputDecoration(
hintText: 'Description, notes, amount…',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 14),
Text('Period', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<bool>(
value: _monthOnly,
onChanged: (value) =>
setState(() => _monthOnly = value),
options: const [
_RectToggleOption(
value: false,
label: 'All',
),
_RectToggleOption(
value: true,
label: 'Month',
icon: Icons.calendar_month,
),
],
),
if (_monthOnly) ...[
const SizedBox(height: 8),
Text(
'Uses ${formatMonthYear(widget.currentMonth)}. '
'Change month on the transactions list.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
const SizedBox(height: 14),
Text('Income / expense', style: sectionStyle),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
const SizedBox(height: 14),
Text('Amount', style: sectionStyle),
const SizedBox(height: 8),
Text(
'Exact',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
TextField(
controller: _exactAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
isDense: true,
hintText: 'e.g. 12.50',
prefixIcon: const Icon(Icons.pin, size: 20),
suffixIcon:
_exactAmountController.text.trim().isEmpty
? null
: IconButton(
tooltip: 'Clear exact',
onPressed: () {
_exactAmountController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) {
setState(() {
_rangeTouched = false;
_amountRange = RangeValues(0, _amountCeil);
});
},
),
const SizedBox(height: 12),
Text(
'Range',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_rangeTouched
? '${_amountRange.start.toStringAsFixed(0)} – ${_amountRange.end.toStringAsFixed(0)}'
: 'Any amount',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
RangeSlider(
values: _amountRange,
min: 0,
max: _amountCeil,
labels: RangeLabels(
_amountRange.start.toStringAsFixed(0),
_amountRange.end.toStringAsFixed(0),
),
onChanged: (values) {
setState(() {
_exactAmountController.clear();
_rangeTouched = true;
_amountRange = values;
});
},
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: !_rangeTouched
? null
: () {
setState(() {
_rangeTouched = false;
_amountRange =
RangeValues(0, _amountCeil);
});
},
child: const Text('Reset range'),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Labels', style: sectionStyle),
const SizedBox(height: 8),
TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) => setState(() {}),
),
Row(
children: [
TextButton(
onPressed: () {
setState(() {
_selected
..clear()
..addAll(entries.map((e) => e.label.id));
if (showUncategorized) {
_selected.add(kUncategorizedFilterId);
}
});
},
child: const Text('All labels'),
),
TextButton(
onPressed: () =>
setState(() => _selected.clear()),
child: const Text('No labels'),
),
],
),
],
),
),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: _LabelChecklist(
entries: entries,
showUncategorized: showUncategorized,
selected: _selected,
onToggle: _toggle,
allowMove: false,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: _apply,
child: const Text('Apply'),
),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
),
],
),
actionsAlignment: MainAxisAlignment.spaceBetween,
actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
actions: [
Wrap(
spacing: 4,
children: [
TextButton(
onPressed: () {
setState(() {
_selected
..clear()
..addAll(entries.map((e) => e.label.id));
if (showUncategorized) {
_selected.add(kUncategorizedFilterId);
}
});
},
child: const Text('All labels'),
),
TextButton(
onPressed: () => setState(() => _selected.clear()),
child: const Text('No labels'),
),
],
),
Wrap(
spacing: 8,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _apply,
child: const Text('Apply'),
),
],
),
],
);
}
}
@@ -879,65 +924,80 @@ class _RectToggleBar<T> extends StatelessWidget {
return SizedBox(
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
children: [
for (var i = 0; i < options.length; i++) ...[
if (i > 0)
Container(
width: 1,
height: 36,
color: colors.outlineVariant,
),
Expanded(
child: Material(
color: options[i].value == value
? colors.secondaryContainer
: colors.surface,
child: InkWell(
onTap: () => onChanged(options[i].value),
child: SizedBox(
height: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (options[i].icon != null) ...[
Icon(
options[i].icon,
size: 15,
color: options[i].value == value
? colors.onSecondaryContainer
: colors.onSurfaceVariant,
),
const SizedBox(width: 4),
],
Flexible(
child: Text(
options[i].label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: colors.surfaceContainerHigh,
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.7),
),
),
child: Row(
children: [
for (var i = 0; i < options.length; i++) ...[
if (i > 0)
Container(
width: 1,
height: 36,
color: colors.outlineVariant.withValues(alpha: 0.7),
),
Expanded(
child: Material(
color: options[i].value == value
? colors.primary
: Colors.transparent,
borderRadius: BorderRadius.horizontal(
left: i == 0 ? const Radius.circular(11) : Radius.zero,
right: i == options.length - 1
? const Radius.circular(11)
: Radius.zero,
),
child: InkWell(
onTap: () => onChanged(options[i].value),
borderRadius: BorderRadius.horizontal(
left: i == 0 ? const Radius.circular(11) : Radius.zero,
right: i == options.length - 1
? const Radius.circular(11)
: Radius.zero,
),
child: SizedBox(
height: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (options[i].icon != null) ...[
Icon(
options[i].icon,
size: 15,
color: options[i].value == value
? colors.onSecondaryContainer
: colors.onSurface,
fontWeight: options[i].value == value
? FontWeight.w600
: FontWeight.w500,
? colors.onPrimary
: colors.onSurfaceVariant,
),
const SizedBox(width: 4),
],
Flexible(
child: Text(
options[i].label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: options[i].value == value
? colors.onPrimary
: colors.onSurface,
fontWeight: options[i].value == value
? FontWeight.w700
: FontWeight.w500,
),
),
),
),
],
],
),
),
),
),
),
),
],
],
],
),
),
),
);
}
}
+3 -2
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../models/bank.dart';
import '../models/transaction_meta.dart';
import '../utils/app_theme.dart';
import '../utils/helpers.dart';
class _RegexExample {
@@ -256,8 +257,8 @@ class _SimilarMatchDialogState extends State<_SimilarMatchDialog> {
formatMoney(tx.amount, tx.currency),
style: theme.textTheme.bodyMedium?.copyWith(
color: tx.amount >= 0
? colors.primary
: colors.error,
? AppTheme.accent
: AppTheme.expense,
fontWeight:
isPrototype ? FontWeight.w700 : null,
),