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? onDraggingChanged; @override State createState() => _WeekPlanViewState(); } class _WeekPlanViewState extends State { /// 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().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 _dropOnWeek(PlanEntry entry, int direction) async { final weekStart = context.read().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().moveEntry(entry.id, day, entry.mealId); _goTo(page); } Future _remove(PlanEntry entry) async { final recipes = context.read().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(); 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( 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 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( 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, ], ), ), ); }, ); } }