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