Initial commit: CookIT recipe collection and week planner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 22:55:09 +01:00
commit 7d7a0bc60b
173 files changed
+22803

No files matched your search

+33
View File
@@ -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.
}
}
}
+144
View File
@@ -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();
}
+107
View File
@@ -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;
}
}
+20
View File
@@ -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;
}
+121
View File
@@ -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),
),
),
],
),
);
}
}