Initial commit: CookIT recipe collection and week planner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7d7a0bc60b
173 files changed
+22803
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// CookIt's own bit of Android (MainActivity.kt): files opened with or
|
||||
/// shared to the app, and full screen brightness for showing a Recipe Code.
|
||||
class CookItPlatform {
|
||||
static const _channel = MethodChannel('cookit/platform');
|
||||
|
||||
/// Calls [onFile] for the file the app was opened with, and for every one
|
||||
/// that arrives while it runs.
|
||||
static void listenForFiles(ValueChanged<Uint8List> onFile) {
|
||||
Future<void> take() async {
|
||||
try {
|
||||
final bytes = await _channel.invokeMethod<Uint8List>('takeIncomingFile');
|
||||
if (bytes != null) onFile(bytes);
|
||||
} on MissingPluginException {
|
||||
// Tests and other platforms: no files arrive.
|
||||
}
|
||||
}
|
||||
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'incomingFile') await take();
|
||||
});
|
||||
take();
|
||||
}
|
||||
|
||||
static Future<void> setMaxBrightness(bool on) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('setMaxBrightness', on);
|
||||
} on MissingPluginException {
|
||||
// Tests and other platforms keep their brightness.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:qr/qr.dart';
|
||||
|
||||
import '../bundle/bundle_codec.dart';
|
||||
import '../recipes/models/recipe.dart';
|
||||
import 'shared_recipe.dart';
|
||||
|
||||
enum RecipeCodeError {
|
||||
/// Some other QR code.
|
||||
notARecipeCode,
|
||||
|
||||
/// Written by a newer CookIt than this one.
|
||||
newerVersion,
|
||||
|
||||
/// Starts like a Recipe Code but doesn't read.
|
||||
damaged,
|
||||
|
||||
/// The Recipe doesn't fit into one QR code.
|
||||
tooLong,
|
||||
}
|
||||
|
||||
class RecipeCodeException implements Exception {
|
||||
const RecipeCodeException(this.error);
|
||||
|
||||
final RecipeCodeError error;
|
||||
|
||||
@override
|
||||
String toString() => 'RecipeCodeException($error)';
|
||||
}
|
||||
|
||||
/// The Recipe Code format (docs/adr/0005): `COOKIT1:` followed by the Recipe's
|
||||
/// JSON, deflated and written in Base45 so the whole text fits QR's compact
|
||||
/// alphanumeric mode.
|
||||
abstract final class RecipeCode {
|
||||
static const _magic = 'COOKIT';
|
||||
static const version = 1;
|
||||
|
||||
static String encode(
|
||||
Recipe recipe, {
|
||||
String? courseName,
|
||||
List<BundleUnit> units = const [],
|
||||
bool includeNotes = true,
|
||||
}) {
|
||||
final json = BundleCodec.encodeRecipe(recipe, courseName: courseName, photoPaths: const [])
|
||||
// The receiver's copy is its own: new id, not a favourite, nothing to review.
|
||||
..remove('id')
|
||||
..remove('photos')
|
||||
..remove('favourite')
|
||||
..remove('reviewReasons');
|
||||
if (!includeNotes) json.remove('notes');
|
||||
json.removeWhere((_, value) => value == null || (value is List && value.isEmpty));
|
||||
final payload = {
|
||||
'recipe': json,
|
||||
if (units.isNotEmpty) 'units': [for (final u in units) u.toJson()],
|
||||
};
|
||||
final deflated = Deflate(utf8.encode(jsonEncode(payload)), level: DeflateLevel.bestCompression).getBytes();
|
||||
return '$_magic$version:${base45Encode(deflated)}';
|
||||
}
|
||||
|
||||
/// Throws [RecipeCodeException].
|
||||
static SharedRecipe decode(String text, {required DateTime now}) {
|
||||
if (!text.startsWith(_magic)) throw const RecipeCodeException(RecipeCodeError.notARecipeCode);
|
||||
final colon = text.indexOf(':');
|
||||
final v = colon < 0 ? null : int.tryParse(text.substring(_magic.length, colon));
|
||||
if (v == null) throw const RecipeCodeException(RecipeCodeError.notARecipeCode);
|
||||
if (v > version) throw const RecipeCodeException(RecipeCodeError.newerVersion);
|
||||
try {
|
||||
final inflated = Inflate(base45Decode(text.substring(colon + 1))).getBytes();
|
||||
final payload = jsonDecode(utf8.decode(inflated)) as Map<String, dynamic>;
|
||||
final entry = BundleCodec.decodeRecipe(payload['recipe'] as Map<String, dynamic>, now: now);
|
||||
return SharedRecipe(recipe: entry.recipe, courseName: entry.courseName, units: BundleCodec.decodeUnits(payload));
|
||||
} catch (_) {
|
||||
throw const RecipeCodeException(RecipeCodeError.damaged);
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest QR code holding [text], with medium error correction when it
|
||||
/// fits and low otherwise. Throws [RecipeCodeException] (tooLong).
|
||||
static QrCode qrCode(String text) {
|
||||
for (final level in const [QrErrorCorrectLevel.M, QrErrorCorrectLevel.L]) {
|
||||
for (var typeNumber = 1; typeNumber <= 40; typeNumber++) {
|
||||
final code = QrCode(typeNumber, level)..addAlphaNumeric(text);
|
||||
try {
|
||||
QrImage(code); // lays out the modules; throws when the text doesn't fit
|
||||
return code;
|
||||
} on InputTooLongException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw const RecipeCodeException(RecipeCodeError.tooLong);
|
||||
}
|
||||
}
|
||||
|
||||
const _base45 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ \$%*+-./:';
|
||||
|
||||
/// Base45 (RFC 9285): two bytes become three characters of QR's alphanumeric set.
|
||||
String base45Encode(List<int> bytes) {
|
||||
final out = StringBuffer();
|
||||
for (var i = 0; i < bytes.length; i += 2) {
|
||||
if (i + 1 < bytes.length) {
|
||||
var n = bytes[i] * 256 + bytes[i + 1];
|
||||
for (var k = 0; k < 3; k++) {
|
||||
out.write(_base45[n % 45]);
|
||||
n ~/= 45;
|
||||
}
|
||||
} else {
|
||||
final n = bytes[i];
|
||||
out
|
||||
..write(_base45[n % 45])
|
||||
..write(_base45[n ~/ 45]);
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// Throws [FormatException] for text that isn't Base45.
|
||||
Uint8List base45Decode(String text) {
|
||||
int value(int i) {
|
||||
final v = _base45.indexOf(text[i]);
|
||||
if (v < 0) throw FormatException('Not Base45', text, i);
|
||||
return v;
|
||||
}
|
||||
|
||||
if (text.length % 3 == 1) throw FormatException('Not Base45', text);
|
||||
final out = BytesBuilder();
|
||||
for (var i = 0; i < text.length; i += 3) {
|
||||
if (i + 2 < text.length) {
|
||||
final n = value(i) + value(i + 1) * 45 + value(i + 2) * 45 * 45;
|
||||
if (n > 0xFFFF) throw FormatException('Not Base45', text, i);
|
||||
out
|
||||
..addByte(n >> 8)
|
||||
..addByte(n & 0xFF);
|
||||
} else {
|
||||
final n = value(i) + value(i + 1) * 45;
|
||||
if (n > 0xFF) throw FormatException('Not Base45', text, i);
|
||||
out.addByte(n);
|
||||
}
|
||||
}
|
||||
return out.toBytes();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../bundle/bundle_service.dart';
|
||||
import '../recipes/course_repository.dart';
|
||||
import '../recipes/models/recipe.dart';
|
||||
import '../recipes/photo_store.dart';
|
||||
import '../recipes/recipe_repository.dart';
|
||||
import '../recipes/unit_repository.dart';
|
||||
import 'recipe_code.dart';
|
||||
import 'shared_recipe.dart';
|
||||
|
||||
/// Recipes going to and coming from another cook's phone: as a Recipe Code or
|
||||
/// as a one-Recipe Recipe Bundle file. Both arrive as a [SharedRecipe] that
|
||||
/// the cook looks at before saving it.
|
||||
class RecipeShareService {
|
||||
RecipeShareService(
|
||||
this._recipes,
|
||||
this._courses,
|
||||
this._units,
|
||||
this._photos,
|
||||
this._bundles, {
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now;
|
||||
|
||||
final RecipeRepository _recipes;
|
||||
final CourseRepository _courses;
|
||||
final UnitRepository _units;
|
||||
final PhotoStore _photos;
|
||||
final BundleService _bundles;
|
||||
final DateTime Function() _clock;
|
||||
|
||||
/// The Recipe Code text for [recipe].
|
||||
Future<String> recipeCode(Recipe recipe, {bool includeNotes = true}) async {
|
||||
final course = recipe.courseId == null
|
||||
? null
|
||||
: (await _courses.allCourses()).where((c) => c.id == recipe.courseId).firstOrNull;
|
||||
return RecipeCode.encode(
|
||||
recipe,
|
||||
courseName: course?.name,
|
||||
units: await _bundles.ownUnitsOf([recipe]),
|
||||
includeNotes: includeNotes,
|
||||
);
|
||||
}
|
||||
|
||||
/// "Als Datei teilen": the Recipe with its Photos as a Recipe Bundle.
|
||||
Future<Uint8List> recipeFile(Recipe recipe, {bool includeNotes = true}) =>
|
||||
_bundles.exportRecipe(recipe, includeNotes: includeNotes);
|
||||
|
||||
SharedRecipe readCode(String text) => RecipeCode.decode(text, now: _clock());
|
||||
|
||||
/// The Recipe in a received file, or null when the file holds a whole
|
||||
/// collection (that one is imported as usual). Throws BundleFormatException.
|
||||
Future<SharedRecipe?> readFile(Uint8List bytes) async => fromContents(await _bundles.read(bytes));
|
||||
|
||||
/// The Recipe of a one-Recipe bundle, or null for any other bundle.
|
||||
SharedRecipe? fromContents(BundleContents contents) {
|
||||
if (contents.recipes.length != 1 || contents.plan != null) return null;
|
||||
final entry = contents.recipes.single;
|
||||
return SharedRecipe(
|
||||
recipe: entry.recipe,
|
||||
courseName: entry.courseName,
|
||||
units: contents.units,
|
||||
photos: [
|
||||
for (final path in entry.photoPaths)
|
||||
if (contents.files[path] case final bytes?) bytes,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the collection already has a Recipe called [name].
|
||||
Future<bool> hasRecipeNamed(String name) async =>
|
||||
(await _recipes.allRecipes()).any((r) => r.name.trim().toLowerCase() == name.trim().toLowerCase());
|
||||
|
||||
/// Adds [shared] as a new Recipe of its own – also when one like it exists –
|
||||
/// creating its Course and own units here if needed.
|
||||
Future<Recipe> save(SharedRecipe shared) async {
|
||||
final known = {for (final u in await _units.allUnits()) u.key};
|
||||
for (final unit in shared.units) {
|
||||
if (!known.contains(unit.key)) await _units.addOwn(unit.label, key: unit.key, direct: unit.direct);
|
||||
}
|
||||
final courseName = shared.courseName;
|
||||
final courseId = courseName == null ? null : (await _courses.findOrCreate(courseName)).id;
|
||||
final now = _clock();
|
||||
final source = shared.recipe;
|
||||
final recipe = Recipe(
|
||||
id: const Uuid().v4(),
|
||||
name: source.name,
|
||||
courseId: courseId,
|
||||
tags: source.tags,
|
||||
servings: source.servings,
|
||||
prepMinutes: source.prepMinutes,
|
||||
cookMinutes: source.cookMinutes,
|
||||
calories: source.calories,
|
||||
ingredientGroups: source.ingredientGroups,
|
||||
stepSections: source.stepSections,
|
||||
photos: [for (final bytes in shared.photos) await _photos.saveBytes(bytes)],
|
||||
sources: source.sources,
|
||||
notes: source.notes,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _recipes.saveRecipe(recipe);
|
||||
return recipe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../bundle/bundle_codec.dart';
|
||||
import '../recipes/models/recipe.dart';
|
||||
|
||||
/// A Recipe arriving from another cook – scanned from a Recipe Code or opened
|
||||
/// from a one-Recipe Recipe Bundle – before it gets an id, a Course and
|
||||
/// Photos in this collection.
|
||||
class SharedRecipe {
|
||||
const SharedRecipe({required this.recipe, this.courseName, this.units = const [], this.photos = const []});
|
||||
|
||||
final Recipe recipe;
|
||||
final String? courseName;
|
||||
|
||||
/// The sender's own units this Recipe uses.
|
||||
final List<BundleUnit> units;
|
||||
|
||||
/// Photo files in order (only a Recipe Bundle carries them).
|
||||
final List<Uint8List> photos;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../recipe_code.dart';
|
||||
import '../recipe_share_service.dart';
|
||||
import 'shared_recipe_view.dart';
|
||||
|
||||
/// "Rezept scannen": the camera, looking for a Recipe Code. The first one that
|
||||
/// reads opens its preview; anything else says what is wrong and keeps looking.
|
||||
class ScanRecipeView extends StatefulWidget {
|
||||
const ScanRecipeView({super.key});
|
||||
|
||||
static const routeName = '/scan';
|
||||
|
||||
@override
|
||||
State<ScanRecipeView> createState() => _ScanRecipeViewState();
|
||||
}
|
||||
|
||||
class _ScanRecipeViewState extends State<ScanRecipeView> {
|
||||
final _scanner = MobileScannerController(formats: const [BarcodeFormat.qrCode]);
|
||||
String? _lastValue;
|
||||
String? _problem;
|
||||
var _done = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scanner.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final value = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull;
|
||||
if (_done || value == null || value == _lastValue) return;
|
||||
_lastValue = value;
|
||||
try {
|
||||
final shared = context.read<RecipeShareService>().readCode(value);
|
||||
_done = true;
|
||||
_scanner.stop();
|
||||
Navigator.pushReplacementNamed(context, SharedRecipeView.routeName, arguments: shared);
|
||||
} on RecipeCodeException catch (e) {
|
||||
setState(
|
||||
() => _problem = switch (e.error) {
|
||||
RecipeCodeError.newerVersion => l10n.codeFromNewerVersion,
|
||||
RecipeCodeError.damaged => l10n.codeDamaged,
|
||||
RecipeCodeError.notARecipeCode || RecipeCodeError.tooLong => l10n.notARecipeCode,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.scanRecipe),
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
// Over the camera picture, whatever the theme says.
|
||||
titleTextStyle: Theme.of(context).appBarTheme.titleTextStyle?.copyWith(color: Colors.white),
|
||||
),
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _scanner,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
l10n.cameraUnavailable,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 260,
|
||||
height: 260,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 24,
|
||||
right: 24,
|
||||
bottom: 48 + MediaQuery.paddingOf(context).bottom,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _problem == null ? Colors.black54 : const Color(0xFFFFE29A),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
_problem ?? l10n.scanHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 15,
|
||||
color: _problem == null ? Colors.white : const Color(0xFF4F3A00),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../design/cookit_colors.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../../recipes/models/recipe.dart';
|
||||
import '../../recipes/recipe_repository.dart';
|
||||
import '../cookit_platform.dart';
|
||||
import '../recipe_code.dart';
|
||||
import '../recipe_share_service.dart';
|
||||
|
||||
/// "Rezept teilen": the Recipe Code, big and bright for another phone to scan,
|
||||
/// and "Als Datei teilen" for sending it with its Photos.
|
||||
class ShareRecipeView extends StatefulWidget {
|
||||
const ShareRecipeView({super.key, required this.recipeId});
|
||||
|
||||
static const routeName = '/share';
|
||||
|
||||
final String recipeId;
|
||||
|
||||
@override
|
||||
State<ShareRecipeView> createState() => _ShareRecipeViewState();
|
||||
}
|
||||
|
||||
class _ShareRecipeViewState extends State<ShareRecipeView> {
|
||||
Recipe? _recipe;
|
||||
var _includeNotes = true;
|
||||
QrCode? _code;
|
||||
var _tooLong = false;
|
||||
var _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_brightAndAwake(true);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_brightAndAwake(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Full brightness and no screen timeout while the code is up.
|
||||
Future<void> _brightAndAwake(bool on) async {
|
||||
await CookItPlatform.setMaxBrightness(on);
|
||||
try {
|
||||
await WakelockPlus.toggle(enable: on);
|
||||
} on PlatformException {
|
||||
// Not fatal: the code can be scanned with the normal screen timeout too.
|
||||
} on MissingPluginException {
|
||||
// Widget tests and platforms without the plugin.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final recipe = await context.read<RecipeRepository>().getRecipe(widget.recipeId);
|
||||
if (!mounted || recipe == null) return;
|
||||
_recipe = recipe;
|
||||
await _makeCode();
|
||||
}
|
||||
|
||||
Future<void> _makeCode() async {
|
||||
final text = await context.read<RecipeShareService>().recipeCode(_recipe!, includeNotes: _includeNotes);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
try {
|
||||
_code = RecipeCode.qrCode(text);
|
||||
_tooLong = false;
|
||||
} on RecipeCodeException {
|
||||
_code = null;
|
||||
_tooLong = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _shareFile() async {
|
||||
final recipe = _recipe!;
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
final bytes = await context.read<RecipeShareService>().recipeFile(recipe, includeNotes: _includeNotes);
|
||||
final name = recipe.name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '-').trim();
|
||||
final file = File('${(await getTemporaryDirectory()).path}/${name.isEmpty ? 'Rezept' : name}.cookit');
|
||||
await file.writeAsBytes(bytes);
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(file.path, mimeType: 'application/zip')]));
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final c = CookItColors.of(context);
|
||||
final recipe = _recipe;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.shareRecipe)),
|
||||
body: recipe == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
|
||||
children: [
|
||||
Text(
|
||||
recipe.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Always black on white, also in the dark theme: that is what scanners read best.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _code != null
|
||||
? QrImageView.withQr(
|
||||
qr: _code!,
|
||||
backgroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(8),
|
||||
semanticsLabel: l10n.shareRecipe,
|
||||
)
|
||||
: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _tooLong
|
||||
? Text(
|
||||
l10n.codeTooLong,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontWeight: FontWeight.w700, color: CookItColors.light.ink),
|
||||
)
|
||||
: const CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.shareRecipeHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: c.inkMuted),
|
||||
),
|
||||
if (recipe.notes != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.includeNotes),
|
||||
value: _includeNotes,
|
||||
onChanged: (value) {
|
||||
setState(() => _includeNotes = value);
|
||||
_makeCode();
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _sending ? null : _shareFile,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
label: Text(l10n.shareAsFile),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.shareAsFileHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: c.inkMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../design/cookit_colors.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../../recipes/amount.dart';
|
||||
import '../../recipes/models/recipe.dart';
|
||||
import '../../recipes/time_format.dart';
|
||||
import '../../recipes/unit_catalog.dart';
|
||||
import '../../recipes/views/recipe_detail_view.dart';
|
||||
import '../recipe_share_service.dart';
|
||||
import '../shared_recipe.dart';
|
||||
|
||||
/// A Recipe from another cook, shown before anything is saved: what it holds,
|
||||
/// a hint when one of that name exists already, and "Speichern".
|
||||
class SharedRecipeView extends StatefulWidget {
|
||||
const SharedRecipeView({super.key, required this.shared});
|
||||
|
||||
static const routeName = '/received';
|
||||
|
||||
final SharedRecipe shared;
|
||||
|
||||
@override
|
||||
State<SharedRecipeView> createState() => _SharedRecipeViewState();
|
||||
}
|
||||
|
||||
class _SharedRecipeViewState extends State<SharedRecipeView> {
|
||||
late final Future<bool> _nameTaken = context.read<RecipeShareService>().hasRecipeNamed(widget.shared.recipe.name);
|
||||
var _saving = false;
|
||||
|
||||
Future<void> _save() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() => _saving = true);
|
||||
final recipe = await context.read<RecipeShareService>().save(widget.shared);
|
||||
navigator.pushReplacementNamed(RecipeDetailView.routeName, arguments: recipe.id);
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.recipeSaved(recipe.name))));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final c = CookItColors.of(context);
|
||||
final units = context.watch<UnitCatalog>();
|
||||
final shared = widget.shared;
|
||||
final recipe = shared.recipe;
|
||||
final sentUnits = {for (final u in shared.units) u.key: u.label};
|
||||
String unitLabel(String key) =>
|
||||
units.byKey(key) != null || !sentUnits.containsKey(key) ? units.label(l10n, key) : sentUnits[key]!;
|
||||
final facts = [
|
||||
if (shared.courseName != null) shared.courseName!,
|
||||
if (recipe.servings != null) '${formatNumber(recipe.servings!)} ${l10n.factServings}',
|
||||
if (recipe.totalMinutes != null) formatMinutes(l10n, recipe.totalMinutes!),
|
||||
].join(' · ');
|
||||
final heading = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w800, fontSize: 17, color: c.green);
|
||||
var number = 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.receivedRecipe)),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: const Icon(Icons.download_done_rounded),
|
||||
label: Text(l10n.save),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
children: [
|
||||
if (shared.photos.isNotEmpty) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 10,
|
||||
child: Image.memory(shared.photos.first, fit: BoxFit.cover, cacheWidth: 1000),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
recipe.name,
|
||||
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 28, color: c.ink),
|
||||
),
|
||||
if (facts.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
facts,
|
||||
style: TextStyle(fontWeight: FontWeight.w700, color: c.inkMuted),
|
||||
),
|
||||
],
|
||||
FutureBuilder<bool>(
|
||||
future: _nameTaken,
|
||||
builder: (context, snapshot) => snapshot.data != true
|
||||
? const SizedBox.shrink()
|
||||
: Container(
|
||||
margin: const EdgeInsets.only(top: 14),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: c.honey, borderRadius: BorderRadius.circular(14)),
|
||||
child: Text(
|
||||
l10n.recipeNameTaken(recipe.name),
|
||||
style: TextStyle(fontWeight: FontWeight.w700, color: c.onHoney),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.ingredients, style: heading),
|
||||
if (recipe.ingredientGroups.isEmpty) Text(l10n.noIngredients),
|
||||
for (final group in recipe.ingredientGroups) ...[
|
||||
if (group.name != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 2),
|
||||
child: Text(
|
||||
group.name!,
|
||||
style: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
|
||||
),
|
||||
),
|
||||
for (final line in group.lines) _IngredientLine(line: line, unitLabel: unitLabel),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.steps, style: heading),
|
||||
if (recipe.stepSections.isEmpty) Text(l10n.noSteps),
|
||||
for (final section in recipe.stepSections) ...[
|
||||
if (section.title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 2),
|
||||
child: Text(
|
||||
section.title!,
|
||||
style: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
|
||||
),
|
||||
),
|
||||
for (final step in section.steps)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text('${++number}. $step', style: TextStyle(fontSize: 15, height: 1.4, color: c.ink)),
|
||||
),
|
||||
],
|
||||
if (recipe.notes != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.notes, style: heading),
|
||||
Text(recipe.notes!, style: TextStyle(fontSize: 15, height: 1.4, color: c.ink)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IngredientLine extends StatelessWidget {
|
||||
const _IngredientLine({required this.line, required this.unitLabel});
|
||||
|
||||
final IngredientLine line;
|
||||
final String Function(String key) unitLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = CookItColors.of(context);
|
||||
final amount = [
|
||||
formatAmount(line.amount, line.amountMax),
|
||||
if (line.unitKey != null) unitLabel(line.unitKey!),
|
||||
].where((s) => s.isNotEmpty).join(' ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
amount,
|
||||
style: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: line.name,
|
||||
children: [
|
||||
if (line.remark != null)
|
||||
TextSpan(
|
||||
text: ' ${line.remark}',
|
||||
style: TextStyle(color: c.inkMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextStyle(color: c.ink),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user