Files
CookIT/lib/src/planning/views/week_plan_view.dart
T

395 lines
14 KiB
Dart
Raw Normal View History

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,
],
),
),
);
},
);
}
}