new plus button
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)!;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user