new plus button

This commit is contained in:
davmar
2026-08-31 09:40:38 +02:00
parent 3b698a3667
commit 92581e6d55
17 changed files with 1336 additions and 152 deletions
+5 -1
View File
@@ -9,6 +9,7 @@ Future<String?> showNameDialog(
String? initial,
String label = 'Name',
String confirmLabel = 'Save',
bool allowEmpty = false,
}) {
return showDialog<String>(
context: context,
@@ -17,6 +18,7 @@ Future<String?> showNameDialog(
initial: initial ?? '',
label: label,
confirmLabel: confirmLabel,
allowEmpty: allowEmpty,
),
);
}
@@ -27,12 +29,14 @@ class _NameDialog extends StatefulWidget {
required this.initial,
required this.label,
required this.confirmLabel,
required this.allowEmpty,
});
final String title;
final String initial;
final String label;
final String confirmLabel;
final bool allowEmpty;
@override
State<_NameDialog> createState() => _NameDialogState();
@@ -56,7 +60,7 @@ class _NameDialogState extends State<_NameDialog> {
void _save() {
final name = _controller.text.trim();
if (name.isEmpty) {
if (name.isEmpty && !widget.allowEmpty) {
setState(() => _error = 'Enter a name.');
return;
}
+216
View File
@@ -0,0 +1,216 @@
/// GitHub-style contribution heatmap for one calendar month of shopping trips.
library;
import 'package:flutter/material.dart';
import '../utils/dates.dart';
import '../utils/money.dart';
import '../utils/shop_calendar.dart';
class ShopHeatmap extends StatelessWidget {
const ShopHeatmap({
super.key,
required this.month,
required this.counts,
required this.monthTotal,
required this.monthTripCount,
required this.canGoForward,
this.selectedDay,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
});
final DateTime month;
final Map<DateTime, int> counts;
final double monthTotal;
final int monthTripCount;
final bool canGoForward;
final DateTime? selectedDay;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final cells = monthGrid(month);
final today = dateOnly(DateTime.now());
final weeks = cells.length ~/ 7;
return Card(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
tooltip: 'Previous month',
onPressed: onPreviousMonth,
icon: const Icon(Icons.chevron_left),
),
Expanded(
child: Text(
formatMonthYear(month),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
),
IconButton(
tooltip: 'Next month',
onPressed: canGoForward ? onNextMonth : null,
icon: const Icon(Icons.chevron_right),
),
],
),
Text(
monthTripCount == 0
? 'No trips this month'
: '${monthTripCount == 1 ? '1 trip' : '$monthTripCount trips'} · ${formatEuro(monthTotal)}',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
for (final label in _weekdays)
Expanded(
child: Text(
label,
textAlign: TextAlign.center,
style: theme.textTheme.labelMedium,
),
),
],
),
const SizedBox(height: 4),
for (var week = 0; week < weeks; week++)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
for (var weekday = 0; weekday < 7; weekday++)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _HeatCell(
date: cells[week * 7 + weekday],
count: cells[week * 7 + weekday] == null
? 0
: counts[cells[week * 7 + weekday]] ?? 0,
today: today,
selectedDay: selectedDay,
onSelect: onSelectDay,
),
),
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Text('Less', style: theme.textTheme.labelMedium),
const SizedBox(width: 6),
for (var level = 0; level <= 4; level++)
Padding(
padding: const EdgeInsets.only(right: 3),
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _fillForLevel(scheme, level),
borderRadius: BorderRadius.circular(3),
),
),
),
Text('More', style: theme.textTheme.labelMedium),
],
),
],
),
),
);
}
}
class _HeatCell extends StatelessWidget {
const _HeatCell({
required this.date,
required this.count,
required this.today,
required this.selectedDay,
required this.onSelect,
});
final DateTime? date;
final int count;
final DateTime today;
final DateTime? selectedDay;
final ValueChanged<DateTime?> onSelect;
@override
Widget build(BuildContext context) {
if (date == null) {
return const SizedBox(height: 36);
}
final scheme = Theme.of(context).colorScheme;
final level = heatmapLevel(count);
final selected = selectedDay != null && isSameDay(date!, selectedDay!);
final isToday = isSameDay(date!, today);
final fill = _fillForLevel(scheme, level);
final textColor = level >= 3
? scheme.onPrimary
: scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1);
return Tooltip(
message: count == 0
? formatDate(date!)
: '${formatDate(date!)} · ${count == 1 ? '1 trip' : '$count trips'}',
child: InkWell(
onTap: () => onSelect(selected ? null : date),
borderRadius: BorderRadius.circular(6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: selected
? scheme.primary
: isToday
? scheme.outline
: Colors.transparent,
width: selected ? 2 : 1,
),
),
child: Text(
'${date!.day}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: textColor,
fontWeight: isToday || selected
? FontWeight.w700
: FontWeight.w500,
fontSize: 11,
),
),
),
),
);
}
}
Color _fillForLevel(ColorScheme scheme, int level) {
if (level <= 0) return scheme.surfaceContainerHighest;
final t = (level / 4).clamp(0.28, 1.0);
return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!;
}
+181
View File
@@ -0,0 +1,181 @@
/// Chip row and optional editor for shopping-trip tags.
library;
import 'package:flutter/material.dart';
class TagChips extends StatelessWidget {
const TagChips({
super.key,
required this.tags,
this.onTap,
this.selected,
this.compact = false,
});
final List<String> tags;
final ValueChanged<String>? onTap;
final String? selected;
final bool compact;
@override
Widget build(BuildContext context) {
if (tags.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: compact ? 4 : 6,
runSpacing: compact ? 4 : 6,
children: [
for (final tag in tags)
_TagChip(
label: tag,
selected:
selected != null &&
selected!.toLowerCase() == tag.toLowerCase(),
compact: compact,
onPressed: onTap == null ? null : () => onTap!(tag),
),
],
);
}
}
class TagEditor extends StatefulWidget {
const TagEditor({
super.key,
required this.tags,
required this.onChanged,
this.suggestions = const [],
this.enabled = true,
});
final List<String> tags;
final List<String> suggestions;
final ValueChanged<List<String>> onChanged;
final bool enabled;
@override
State<TagEditor> createState() => _TagEditorState();
}
class _TagEditorState extends State<TagEditor> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add(String raw) {
final label = raw.trim().replaceAll(RegExp(r'\s+'), ' ');
if (label.isEmpty) return;
final exists = widget.tags.any(
(tag) => tag.toLowerCase() == label.toLowerCase(),
);
if (exists) {
_controller.clear();
return;
}
widget.onChanged([...widget.tags, label]);
_controller.clear();
}
void _remove(String tag) {
widget.onChanged([
for (final item in widget.tags)
if (item.toLowerCase() != tag.toLowerCase()) item,
]);
}
@override
Widget build(BuildContext context) {
final unused = [
for (final tag in widget.suggestions)
if (!widget.tags.any((item) => item.toLowerCase() == tag.toLowerCase()))
tag,
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.tags.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tag in widget.tags)
InputChip(
label: Text(tag),
onDeleted: widget.enabled ? () => _remove(tag) : null,
),
],
),
const SizedBox(height: 8),
],
TextField(
controller: _controller,
enabled: widget.enabled,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Add tag',
hintText: 'weekly, bulk, work…',
),
onSubmitted: widget.enabled ? _add : null,
),
if (widget.enabled && unused.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tag in unused.take(12))
ActionChip(label: Text(tag), onPressed: () => _add(tag)),
],
),
],
],
);
}
}
class _TagChip extends StatelessWidget {
const _TagChip({
required this.label,
required this.selected,
required this.compact,
this.onPressed,
});
final String label;
final bool selected;
final bool compact;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final bg = selected ? scheme.primary : scheme.secondaryContainer;
final fg = selected ? scheme.onPrimary : scheme.onSecondaryContainer;
return Material(
color: bg,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 2 : 4,
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: fg,
fontWeight: FontWeight.w600,
fontSize: compact ? 11 : 12,
),
),
),
),
);
}
}