Files
CookIT/lib/src/planning/views/week_plan_view.dart
T
2026-10-28 22:55:09 +01:00

396 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/cookit_colors.dart';
import '../../design/cookit_nav_bar.dart';
import '../../design/herb_pattern.dart';
import '../../localization/app_localizations.dart';
import '../../recipes/recipe_list_provider.dart';
import '../../settings/settings_controller.dart';
import '../day.dart';
import '../day_format.dart';
import '../models/plan_entry.dart';
import '../plan_repository.dart';
import 'widgets/fill_week_sheet.dart';
import 'widgets/plan_actions.dart';
import 'widgets/plan_grid.dart';
import 'widgets/week_share_menu.dart';
import 'widgets/today_banner.dart';
/// The Week Plan: today's banner, then one week as a grid. Swipe (or ‹ ›) for
/// other weeks. While an entry is dragged, the week bar turns into drop zones
/// for the neighbouring weeks and the navigation bar into "remove".
class WeekPlanView extends StatefulWidget {
const WeekPlanView({super.key, this.onDraggingChanged});
/// Lets the home screen hide its navigation bar while an entry is dragged.
final ValueChanged<bool>? onDraggingChanged;
@override
State<WeekPlanView> createState() => _WeekPlanViewState();
}
class _WeekPlanViewState extends State<WeekPlanView> {
/// The page showing the current week; pages before it are past weeks.
static const _origin = 10000;
final _pages = PageController(initialPage: _origin);
var _page = _origin;
var _dragging = false;
@override
void dispose() {
_pages.dispose();
super.dispose();
}
DateTime get _thisWeek => weekStartOf(dayOf(DateTime.now()), context.read<SettingsController>().weekStart);
DateTime _weekOf(int page) => addDays(_thisWeek, 7 * (page - _origin));
void _goTo(int page) =>
_pages.animateToPage(page, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic);
// A drag can outlive the cell it started in (the week flips under it), so it
// ends with the finger, not with the cell's callbacks.
void _setDragging(bool dragging) {
if (_dragging == dragging) return;
setState(() => _dragging = dragging);
widget.onDraggingChanged?.call(dragging);
}
/// Dropped on "Vorige/Nächste Woche": same weekday and Meal in the week now
/// shown, or in the neighbouring one if the week hasn't flipped yet.
Future<void> _dropOnWeek(PlanEntry entry, int direction) async {
final weekStart = context.read<SettingsController>().weekStart;
final entryWeek = weekStartOf(entry.day, weekStart);
var target = _weekOf(_page);
if (target == entryWeek) target = addDays(target, 7 * direction);
final day = addDays(target, (entry.day.weekday - weekStart) % 7);
final page = _origin + target.difference(_thisWeek).inDays ~/ 7;
await context.read<PlanRepository>().moveEntry(entry.id, day, entry.mealId);
_goTo(page);
}
Future<void> _remove(PlanEntry entry) async {
final recipes = context.read<RecipeListProvider>().allRecipes;
final name = recipes.where((r) => r.id == entry.recipeId).firstOrNull?.name ?? entry.note ?? '';
await removeEntryWithUndo(context, entry, name);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
context.watch<SettingsController>();
final today = dayOf(DateTime.now());
final topInset = MediaQuery.paddingOf(context).top;
final bottomInset = MediaQuery.paddingOf(context).bottom;
final shown = _weekOf(_page);
return Listener(
onPointerUp: (_) => _setDragging(false),
onPointerCancel: (_) => _setDragging(false),
// No Scaffold of its own: HomeView's is the one the SnackBars appear in.
child: ColoredBox(
color: c.background,
child: Stack(
children: [
const Positioned.fill(child: ParallaxEdgePattern()),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, topInset + 16, 20, 8),
child: Row(
children: [
Expanded(
child: Text(
l10n.weekPlanTitle,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 32,
color: c.ink,
),
),
),
FilledButton.tonalIcon(
onPressed: () => _goTo(_origin),
icon: const Icon(Icons.today_rounded, size: 18),
label: Text(l10n.jumpToToday),
),
],
),
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: TodayBanner()),
SizedBox(
height: 60,
child: _dragging
? Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Expanded(
child: _WeekDropZone(
label: l10n.previousWeek,
icon: Icons.chevron_left_rounded,
onFlip: () => _goTo(_page - 1),
onDrop: (entry) => _dropOnWeek(entry, -1),
),
),
const SizedBox(width: 8),
Expanded(
child: _WeekDropZone(
label: l10n.nextWeek,
icon: Icons.chevron_right_rounded,
iconAfter: true,
onFlip: () => _goTo(_page + 1),
onDrop: (entry) => _dropOnWeek(entry, 1),
),
),
],
),
)
: _WeekBar(
start: shown,
weeksAway: _page - _origin,
past: addDays(shown, 6).isBefore(today),
onPrevious: () => _goTo(_page - 1),
onNext: () => _goTo(_page + 1),
),
),
Expanded(
child: PageView.builder(
controller: _pages,
onPageChanged: (page) => setState(() => _page = page),
itemBuilder: (context, page) => SingleChildScrollView(
padding: EdgeInsets.fromLTRB(12, 0, 12, 32 + bottomInset + CookItNavBar.height),
child: PlanGrid(weekStart: _weekOf(page), today: today, onDragStarted: () => _setDragging(true)),
),
),
),
],
),
if (_dragging)
Positioned(
left: 16,
right: 16,
bottom: 16 + bottomInset,
height: CookItNavBar.height,
child: DragTarget<PlanEntry>(
onAcceptWithDetails: (d) => _remove(d.data),
builder: (context, candidates, _) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: candidates.isEmpty ? c.tiles[2] : const Color(0xFFF4C4AE),
borderRadius: BorderRadius.circular(CookItNavBar.height / 2),
border: Border.all(color: const Color(0xFFC0563A), width: 2),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete_outline_rounded, color: Color(0xFF8A2E17)),
const SizedBox(width: 8),
Text(
l10n.dropToRemove,
style: const TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFF8A2E17),
),
),
],
),
),
),
),
),
],
),
),
);
}
}
class _WeekBar extends StatelessWidget {
const _WeekBar({
required this.start,
required this.weeksAway,
required this.past,
required this.onPrevious,
required this.onNext,
});
final DateTime start;
final int weeksAway;
final bool past;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final title = switch (weeksAway) {
0 => l10n.thisWeek,
1 => l10n.nextWeek,
-1 => l10n.lastWeek,
_ => l10n.calendarWeek(calendarWeek(start)),
};
final round = IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(40, 40),
);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 16, 0),
child: Row(
children: [
// ‹ title › take what the buttons leave, the title shortening only if it must.
Expanded(
child: Row(
children: [
IconButton(
tooltip: l10n.previousWeek,
onPressed: onPrevious,
icon: Icon(Icons.chevron_left_rounded, color: c.green),
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 17,
color: c.green,
),
),
Text(
dayRange(start, addDays(start, 6)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12, color: c.inkMuted),
),
],
),
),
IconButton(
tooltip: l10n.nextWeek,
onPressed: onNext,
icon: Icon(Icons.chevron_right_rounded, color: c.green),
),
],
),
),
IconButton(
tooltip: l10n.shareMenu,
onPressed: () => showWeekShareMenu(context, start),
style: round,
icon: const Icon(Icons.share_rounded, size: 20),
),
if (!past) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.fillWeek,
onPressed: () => showFillWeekSheet(context, start),
style: round,
icon: const Icon(Icons.casino_outlined, size: 20),
),
],
],
),
);
}
}
/// "Vorige Woche" / "Nächste Woche" while dragging: holding an entry over it
/// flips the week (again every moment it stays there); dropping it there moves it.
class _WeekDropZone extends StatefulWidget {
const _WeekDropZone({
required this.label,
required this.icon,
required this.onFlip,
required this.onDrop,
this.iconAfter = false,
});
final String label;
final IconData icon;
final bool iconAfter;
final VoidCallback onFlip;
final ValueChanged<PlanEntry> onDrop;
@override
State<_WeekDropZone> createState() => _WeekDropZoneState();
}
class _WeekDropZoneState extends State<_WeekDropZone> {
Timer? _flipping;
void _stop() {
_flipping?.cancel();
_flipping = null;
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return DragTarget<PlanEntry>(
onWillAcceptWithDetails: (_) {
_flipping ??= Timer.periodic(const Duration(milliseconds: 800), (_) => widget.onFlip());
return true;
},
onLeave: (_) => _stop(),
onAcceptWithDetails: (d) {
_stop();
widget.onDrop(d.data);
},
builder: (context, candidates, _) {
final icon = Icon(widget.icon, size: 20, color: c.green);
return Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: candidates.isEmpty ? c.surface : c.navBar,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: c.brand, width: 2),
),
padding: const EdgeInsets.symmetric(horizontal: 6),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.iconAfter) icon,
Text(
widget.label,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 14, color: c.green),
),
if (widget.iconAfter) icon,
],
),
),
);
},
);
}
}