395 lines
14 KiB
Dart
395 lines
14 KiB
Dart
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,
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|