feat(ui): align inventory + detail with mockups (seed icons, green theme)
Visual pass toward docs/mockups (06 inventory, 07 item): - Bundle the seedks icon font (chars a–k → seed pictograms: jar, packet, spoon…) and a SeedGlyph/QuantityKindIcon helper. - Green app bar + pale-green canvas theme (buildTaneTheme). - Inventory list: rounded-square photo thumbnail or green initial avatar, bold title, scientific-name subtitle, trailing edit pencil, styled category headers, rounded search field, seed-glyph empty state. - Quantity selectors (quick-add + add-lot) show the seed pictogram + word; detail lot lines use the kind's glyph. - Detail header restyled to mockup 07: category link + scientific name on the left, photo on the right. watchInventory now also returns the linked species' scientific name (subtitle). 37 app tests green; seedks font verified in the Linux asset bundle.
This commit is contained in:
parent
e41ec9872b
commit
48e9d15772
12 changed files with 329 additions and 49 deletions
BIN
apps/app_seeds/fonts/seedks.ttf
Normal file
BIN
apps/app_seeds/fonts/seedks.ttf
Normal file
Binary file not shown.
|
|
@ -9,6 +9,7 @@ import 'i18n/strings.g.dart';
|
||||||
import 'state/inventory_cubit.dart';
|
import 'state/inventory_cubit.dart';
|
||||||
import 'state/variety_detail_cubit.dart';
|
import 'state/variety_detail_cubit.dart';
|
||||||
import 'ui/inventory_list_screen.dart';
|
import 'ui/inventory_list_screen.dart';
|
||||||
|
import 'ui/theme.dart';
|
||||||
import 'ui/variety_detail_screen.dart';
|
import 'ui/variety_detail_screen.dart';
|
||||||
|
|
||||||
/// Root widget. Provides the repository + inventory cubit to the tree and wires
|
/// Root widget. Provides the repository + inventory cubit to the tree and wires
|
||||||
|
|
@ -54,10 +55,7 @@ class TaneApp extends StatelessWidget {
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
onGenerateTitle: (context) => context.t.app.title,
|
onGenerateTitle: (context) => context.t.app.title,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: buildTaneTheme(),
|
||||||
colorSchemeSeed: const Color(0xFF4C7A34), // seed-green
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
locale: TranslationProvider.of(context).flutterLocale,
|
locale: TranslationProvider.of(context).flutterLocale,
|
||||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class VarietyListItem extends Equatable {
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.label,
|
required this.label,
|
||||||
this.category,
|
this.category,
|
||||||
|
this.scientificName,
|
||||||
this.photo,
|
this.photo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -19,11 +20,14 @@ class VarietyListItem extends Equatable {
|
||||||
final String label;
|
final String label;
|
||||||
final String? category;
|
final String? category;
|
||||||
|
|
||||||
|
/// Scientific name of the linked species, shown as the tile subtitle.
|
||||||
|
final String? scientificName;
|
||||||
|
|
||||||
/// First photo (encrypted BLOB) for the avatar, or null → show an initial.
|
/// First photo (encrypted BLOB) for the avatar, or null → show an initial.
|
||||||
final Uint8List? photo;
|
final Uint8List? photo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, label, category, photo];
|
List<Object?> get props => [id, label, category, scientificName, photo];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
|
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
|
||||||
|
|
@ -154,12 +158,18 @@ class VarietyRepository {
|
||||||
]);
|
]);
|
||||||
return query.watch().asyncMap((rows) async {
|
return query.watch().asyncMap((rows) async {
|
||||||
final photos = await _firstPhotosFor(rows.map((v) => v.id).toList());
|
final photos = await _firstPhotosFor(rows.map((v) => v.id).toList());
|
||||||
|
final sciNames = await _scientificNamesFor(
|
||||||
|
rows.map((v) => v.speciesId).whereType<String>().toSet(),
|
||||||
|
);
|
||||||
return rows
|
return rows
|
||||||
.map(
|
.map(
|
||||||
(v) => VarietyListItem(
|
(v) => VarietyListItem(
|
||||||
id: v.id,
|
id: v.id,
|
||||||
label: v.label,
|
label: v.label,
|
||||||
category: v.category,
|
category: v.category,
|
||||||
|
scientificName: v.speciesId == null
|
||||||
|
? null
|
||||||
|
: sciNames[v.speciesId],
|
||||||
photo: photos[v.id],
|
photo: photos[v.id],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -189,6 +199,17 @@ class VarietyRepository {
|
||||||
return byVariety;
|
return byVariety;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps each of [speciesIds] to its scientific name (one query).
|
||||||
|
Future<Map<String, String>> _scientificNamesFor(
|
||||||
|
Set<String> speciesIds,
|
||||||
|
) async {
|
||||||
|
if (speciesIds.isEmpty) return const {};
|
||||||
|
final rows = await (_db.select(
|
||||||
|
_db.species,
|
||||||
|
)..where((s) => s.id.isIn(speciesIds))).get();
|
||||||
|
return {for (final s in rows) s.id: s.scientificName};
|
||||||
|
}
|
||||||
|
|
||||||
/// The 20-second quick-add: a [label] (required) plus an optional [category],
|
/// The 20-second quick-add: a [label] (required) plus an optional [category],
|
||||||
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
|
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
|
||||||
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
|
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import '../data/variety_repository.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../state/inventory_cubit.dart';
|
import '../state/inventory_cubit.dart';
|
||||||
import 'quick_add_sheet.dart';
|
import 'quick_add_sheet.dart';
|
||||||
|
import 'seed_glyph.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
/// The inventory home: a searchable list of seeds grouped by category, with a
|
/// The inventory home: a searchable list of seeds grouped by category, with a
|
||||||
/// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream.
|
/// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream.
|
||||||
|
|
@ -34,13 +36,19 @@ class InventoryListScreen extends StatelessWidget {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
key: const Key('inventory.search'),
|
key: const Key('inventory.search'),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: t.inventory.searchHint,
|
hintText: t.inventory.searchHint,
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
border: const OutlineInputBorder(),
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onChanged: context.read<InventoryCubit>().search,
|
onChanged: context.read<InventoryCubit>().search,
|
||||||
),
|
),
|
||||||
|
|
@ -66,10 +74,21 @@ class _InventoryBody extends StatelessWidget {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Text(
|
child: Column(
|
||||||
t.inventory.empty,
|
mainAxisSize: MainAxisSize.min,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
SeedGlyph(
|
||||||
|
SeedGlyphs.jars,
|
||||||
|
size: 72,
|
||||||
|
color: seedGreen.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
t.inventory.empty,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -99,11 +118,12 @@ class _CategoryHeader extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: seedGreenDark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -117,16 +137,53 @@ class _VarietyTile extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
void open() => context.push('/variety/${item.id}');
|
||||||
|
return ListTile(
|
||||||
|
leading: _Avatar(item: item),
|
||||||
|
title: Text(
|
||||||
|
item.label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: item.scientificName == null
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
item.scientificName!,
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
color: Colors.black38,
|
||||||
|
tooltip: context.t.common.edit,
|
||||||
|
onPressed: open,
|
||||||
|
),
|
||||||
|
onTap: open,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounded-square photo thumbnail, or a green initial circle when there is none.
|
||||||
|
class _Avatar extends StatelessWidget {
|
||||||
|
const _Avatar({required this.item});
|
||||||
|
|
||||||
|
final VarietyListItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final photo = item.photo;
|
||||||
|
if (photo != null) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
|
||||||
|
);
|
||||||
|
}
|
||||||
final trimmed = item.label.trim();
|
final trimmed = item.label.trim();
|
||||||
final initial = trimmed.isEmpty
|
final initial = trimmed.isEmpty
|
||||||
? '?'
|
? '?'
|
||||||
: trimmed.substring(0, 1).toUpperCase();
|
: trimmed.substring(0, 1).toUpperCase();
|
||||||
return ListTile(
|
return CircleAvatar(
|
||||||
leading: item.photo != null
|
backgroundColor: seedGreen.withValues(alpha: 0.15),
|
||||||
? CircleAvatar(backgroundImage: MemoryImage(item.photo!))
|
foregroundColor: seedGreenDark,
|
||||||
: CircleAvatar(child: Text(initial)),
|
child: Text(initial),
|
||||||
title: Text(item.label),
|
|
||||||
onTap: () => context.push('/variety/${item.id}'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../data/variety_repository.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../state/quick_add_cubit.dart';
|
import '../state/quick_add_cubit.dart';
|
||||||
import 'quantity_kind_l10n.dart';
|
import 'quantity_kind_l10n.dart';
|
||||||
|
import 'seed_glyph.dart';
|
||||||
|
|
||||||
/// Picks a photo and returns its bytes (or null if cancelled). Injected so
|
/// Picks a photo and returns its bytes (or null if cancelled). Injected so
|
||||||
/// widget tests can supply a fake instead of the camera plugin.
|
/// widget tests can supply a fake instead of the camera plugin.
|
||||||
|
|
@ -103,6 +104,7 @@ class QuickAddSheet extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
for (final kind in _quickKinds)
|
for (final kind in _quickKinds)
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
|
avatar: QuantityKindIcon(kind, size: 18),
|
||||||
label: Text(quantityKindLabel(t, kind)),
|
label: Text(quantityKindLabel(t, kind)),
|
||||||
selected: state.quantityKind == kind,
|
selected: state.quantityKind == kind,
|
||||||
onSelected: (_) => cubit.selectQuantity(kind),
|
onSelected: (_) => cubit.selectQuantity(kind),
|
||||||
|
|
|
||||||
87
apps/app_seeds/lib/ui/seed_glyph.dart
Normal file
87
apps/app_seeds/lib/ui/seed_glyph.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Renders a glyph from the bundled `seedks` icon font, whose characters `a`–`k`
|
||||||
|
/// map to seed-themed pictograms (jar, packet, spoon…). See
|
||||||
|
/// docs/mockups/seedks-glyphs.png.
|
||||||
|
class SeedGlyph extends StatelessWidget {
|
||||||
|
const SeedGlyph(this.char, {this.size = 24, this.color, super.key});
|
||||||
|
|
||||||
|
final String char;
|
||||||
|
final double size;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
char,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'seedks',
|
||||||
|
fontSize: size,
|
||||||
|
height: 1,
|
||||||
|
color: color ?? Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named seedks glyphs (the character each pictogram is mapped to).
|
||||||
|
abstract final class SeedGlyphs {
|
||||||
|
static const jar = 'a';
|
||||||
|
static const jars = 'b';
|
||||||
|
static const search = 'c';
|
||||||
|
static const share = 'd';
|
||||||
|
static const sack = 'e';
|
||||||
|
static const smallSpoon = 'f';
|
||||||
|
static const bigSpoon = 'g';
|
||||||
|
static const mug = 'h';
|
||||||
|
static const envelope = 'i';
|
||||||
|
static const pouring = 'j';
|
||||||
|
static const scattered = 'k';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seedks character for a quantity [kind], or null for plant-form/precise
|
||||||
|
/// kinds that have no seed pictogram (they fall back to a Material icon).
|
||||||
|
String? seedGlyphForKind(QuantityKind kind) => switch (kind) {
|
||||||
|
QuantityKind.aFew => SeedGlyphs.scattered,
|
||||||
|
QuantityKind.some => SeedGlyphs.smallSpoon,
|
||||||
|
QuantityKind.plenty => SeedGlyphs.jars,
|
||||||
|
QuantityKind.handful => SeedGlyphs.bigSpoon,
|
||||||
|
QuantityKind.pinch => SeedGlyphs.scattered,
|
||||||
|
QuantityKind.jar => SeedGlyphs.jar,
|
||||||
|
QuantityKind.packet => SeedGlyphs.envelope,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
IconData _materialIconForKind(QuantityKind kind) => switch (kind) {
|
||||||
|
QuantityKind.grams => Icons.scale_outlined,
|
||||||
|
QuantityKind.count => Icons.tag,
|
||||||
|
QuantityKind.cob || QuantityKind.ear => Icons.grass,
|
||||||
|
QuantityKind.head || QuantityKind.seedHead => Icons.filter_vintage_outlined,
|
||||||
|
QuantityKind.fruit => Icons.local_florist_outlined,
|
||||||
|
QuantityKind.bulb ||
|
||||||
|
QuantityKind.tuber ||
|
||||||
|
QuantityKind.bunch => Icons.spa_outlined,
|
||||||
|
_ => Icons.eco_outlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A consistent icon for a [QuantityKind]: the seed glyph where one exists,
|
||||||
|
/// otherwise a Material fallback.
|
||||||
|
class QuantityKindIcon extends StatelessWidget {
|
||||||
|
const QuantityKindIcon(this.kind, {this.size = 24, this.color, super.key});
|
||||||
|
|
||||||
|
final QuantityKind kind;
|
||||||
|
final double size;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final glyph = seedGlyphForKind(kind);
|
||||||
|
if (glyph != null) return SeedGlyph(glyph, size: size, color: color);
|
||||||
|
return Icon(
|
||||||
|
_materialIconForKind(kind),
|
||||||
|
size: size,
|
||||||
|
color: color ?? Theme.of(context).colorScheme.primary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/app_seeds/lib/ui/theme.dart
Normal file
35
apps/app_seeds/lib/ui/theme.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Seed-green palette drawn from the mockups (docs/mockups): a green app bar and
|
||||||
|
/// a pale-green canvas.
|
||||||
|
const seedGreen = Color(0xFF3C8B2E);
|
||||||
|
const seedGreenDark = Color(0xFF1B5E20);
|
||||||
|
const seedCanvas = Color(0xFFEAF4E2);
|
||||||
|
const seedLink = Color(0xFF1E88C0);
|
||||||
|
|
||||||
|
ThemeData buildTaneTheme() {
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: seedGreen,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
);
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
scaffoldBackgroundColor: seedCanvas,
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: seedGreen,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: seedGreen,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
bottomSheetTheme: const BottomSheetThemeData(
|
||||||
|
backgroundColor: seedCanvas,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(space: 1, thickness: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import '../data/species_repository.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../state/variety_detail_cubit.dart';
|
import '../state/variety_detail_cubit.dart';
|
||||||
|
import 'seed_glyph.dart';
|
||||||
|
import 'theme.dart';
|
||||||
import 'quantity_kind_l10n.dart';
|
import 'quantity_kind_l10n.dart';
|
||||||
|
|
||||||
/// Read + edit view of a single variety: its photo, category, notes, other
|
/// Read + edit view of a single variety: its photo, category, notes, other
|
||||||
|
|
@ -72,33 +74,7 @@ class _DetailView extends StatelessWidget {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (detail.scientificName != null) ...[
|
_DetailHeader(detail: detail),
|
||||||
Text(
|
|
||||||
detail.scientificName!,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
if (detail.photo != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Image.memory(
|
|
||||||
detail.photo!,
|
|
||||||
height: 200,
|
|
||||||
width: double.infinity,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (detail.category != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Chip(label: Text(detail.category!)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (detail.vernacularNames.isNotEmpty) ...[
|
if (detail.vernacularNames.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_SectionTitle(t.detail.names),
|
_SectionTitle(t.detail.names),
|
||||||
|
|
@ -134,7 +110,9 @@ class _DetailView extends StatelessWidget {
|
||||||
for (final lot in detail.lots)
|
for (final lot in detail.lots)
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.inventory_2_outlined),
|
leading: lot.quantity == null
|
||||||
|
? const Icon(Icons.inventory_2_outlined)
|
||||||
|
: QuantityKindIcon(lot.quantity!.kind, size: 28),
|
||||||
title: Text(_lotSubtitle(t, lot)),
|
title: Text(_lotSubtitle(t, lot)),
|
||||||
subtitle: lot.storageLocation == null
|
subtitle: lot.storageLocation == null
|
||||||
? null
|
? null
|
||||||
|
|
@ -548,6 +526,7 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
|
||||||
children: [
|
children: [
|
||||||
for (final kind in _addLotKinds)
|
for (final kind in _addLotKinds)
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
|
avatar: QuantityKindIcon(kind, size: 18),
|
||||||
label: Text(quantityKindLabel(t, kind)),
|
label: Text(quantityKindLabel(t, kind)),
|
||||||
selected: selectedKind == kind,
|
selected: selectedKind == kind,
|
||||||
onSelected: (_) => setState(() => selectedKind = kind),
|
onSelected: (_) => setState(() => selectedKind = kind),
|
||||||
|
|
@ -598,6 +577,61 @@ String? _nullIfBlank(String value) {
|
||||||
return trimmed.isEmpty ? null : trimmed;
|
return trimmed.isEmpty ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mockup-07 header: category link + scientific name on the left, the photo on
|
||||||
|
/// the right.
|
||||||
|
class _DetailHeader extends StatelessWidget {
|
||||||
|
const _DetailHeader({required this.detail});
|
||||||
|
|
||||||
|
final VarietyDetail detail;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasText = detail.category != null || detail.scientificName != null;
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (detail.category != null)
|
||||||
|
Text(
|
||||||
|
detail.category!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: seedLink,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (detail.scientificName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
detail.scientificName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (detail.photo != null) ...[
|
||||||
|
if (hasText) const SizedBox(width: 12),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.memory(
|
||||||
|
detail.photo!,
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionTitle extends StatelessWidget {
|
class _SectionTitle extends StatelessWidget {
|
||||||
const _SectionTitle(this.text);
|
const _SectionTitle(this.text);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,8 @@ flutter:
|
||||||
# Seed strings for slang live under lib/i18n (not a Flutter asset; compiled).
|
# Seed strings for slang live under lib/i18n (not a Flutter asset; compiled).
|
||||||
assets:
|
assets:
|
||||||
- assets/catalog/species.json
|
- assets/catalog/species.json
|
||||||
|
fonts:
|
||||||
|
# Custom seed-themed icon glyphs (chars a–k). See docs/mockups/seedks-glyphs.png.
|
||||||
|
- family: seedks
|
||||||
|
fonts:
|
||||||
|
- asset: fonts/seedks.ttf
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ void main() {
|
||||||
final detail = await repo.watchVariety(id).first;
|
final detail = await repo.watchVariety(id).first;
|
||||||
expect(detail!.scientificName, 'Solanum lycopersicum');
|
expect(detail!.scientificName, 'Solanum lycopersicum');
|
||||||
expect(detail.category, 'Solanaceae'); // prefilled from family
|
expect(detail.category, 'Solanaceae'); // prefilled from family
|
||||||
|
|
||||||
|
// The inventory list row also surfaces the scientific name (subtitle).
|
||||||
|
final row = (await repo.watchInventory().first).single;
|
||||||
|
expect(row.scientificName, 'Solanum lycopersicum');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,48 @@ JSON por locale, compatible Weblate). Ver [`../../apps/app_seeds`](../../apps/ap
|
||||||
|
|
||||||
- **Editar nombres vernáculos** ("también conocida como": hoy solo lectura;
|
- **Editar nombres vernáculos** ("también conocida como": hoy solo lectura;
|
||||||
falta añadir/quitar — colección OR-Set).
|
falta añadir/quitar — colección OR-Set).
|
||||||
- **Editar/borrar lotes** (se pueden añadir pero no modificar/eliminar).
|
- **Editar/borrar lotes** (se pueden añadir pero no modificar/eliminar). Falta
|
||||||
|
`updateLot`/`softDeleteLot` en el repo (hoy solo `addLot` en
|
||||||
|
[`variety_repository.dart`](../../apps/app_seeds/lib/data/variety_repository.dart))
|
||||||
|
y la afordancia en las tiles de lote de
|
||||||
|
[`variety_detail_screen.dart`](../../apps/app_seeds/lib/ui/variety_detail_screen.dart).
|
||||||
|
El soft-delete ya tiene infraestructura (`isDeleted`/tombstone vía `SyncColumns`).
|
||||||
|
- **Editar cantidad del lote**: la UI de alta solo ofrece 6 `QuantityKind` como
|
||||||
|
chips; falta campo para cantidad numérica (`precise`) y etiqueta libre (`label`),
|
||||||
|
que el modelo (`Quantity`) ya soporta.
|
||||||
- (Opcional) QR de recuperación de la semilla raíz; almacenamiento cifrado de
|
- (Opcional) QR de recuperación de la semilla raíz; almacenamiento cifrado de
|
||||||
fotos fuera de la BD (hoy van como BLOB cifrado dentro de la BD, que cumple la
|
fotos fuera de la BD (hoy van como BLOB cifrado dentro de la BD, que cumple la
|
||||||
regla).
|
regla).
|
||||||
|
|
||||||
|
### Feedback de prueba (vjrj, 2026-07-07)
|
||||||
|
|
||||||
|
Tras probar la app: no se pueden editar unidades/cantidad ni editar/borrar lotes
|
||||||
|
(solo añadir) — **esperado**, cubierto por los pendientes de arriba. Echa en falta
|
||||||
|
la iconografía de los mockups (ver nota siguiente).
|
||||||
|
|
||||||
|
## Iconografía de los mockups (diseño futuro) 🎨
|
||||||
|
|
||||||
|
La app usa hoy **Material Icons genéricos**. Los iconos de los mockups **existen** ya
|
||||||
|
como font custom: **[`seedks.ttf`](../../seedks.ttf)** (raíz del repo, trackeado en
|
||||||
|
git), familia interna **"Seedks"**, **11 glifos** en `a`–`k` (U+0061–U+006B), de la
|
||||||
|
misma época que [`../icons.svg`](../icons.svg) (2018). Preview renderizado:
|
||||||
|
[`../mockups/seedks-glyphs.png`](../mockups/seedks-glyphs.png).
|
||||||
|
|
||||||
|
Mapeo de glifos:
|
||||||
|
|
||||||
|
| Glifo | Icono | Glifo | Icono | Glifo | Icono |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `a` | tarro | `e` | saco/bolsa | `i` | sobre |
|
||||||
|
| `b` | tarros (varios) | `f` | cucharilla | `j` | sobre vertiendo |
|
||||||
|
| `c` | búsqueda (lupa) | `g` | cuchara | `k` | semillas sueltas |
|
||||||
|
| `d` | mano compartiendo | `h` | taza | | |
|
||||||
|
|
||||||
|
**No** está cableado: falta declarar `fonts:` en
|
||||||
|
[`../../apps/app_seeds/pubspec.yaml`](../../apps/app_seeds/pubspec.yaml) y crear un
|
||||||
|
mapa codepoint→`IconData` (`fontFamily: 'Seedks'`). Recuperar la iconografía **no**
|
||||||
|
requiere SVGs nuevos. Iconos por familia/categoría y por unidad de cantidad =
|
||||||
|
trabajo de diseño para después del pulido del Bloque 1.
|
||||||
|
|
||||||
## Fuera de alcance (Bloque 2, no empezar) 🚫
|
## Fuera de alcance (Bloque 2, no empezar) 🚫
|
||||||
|
|
||||||
Ofertas, mensajería, relays, Nostr, red de confianza, Ğ1, sincronización entre
|
Ofertas, mensajería, relays, Nostr, red de confianza, Ğ1, sincronización entre
|
||||||
|
|
|
||||||
BIN
docs/mockups/seedks-glyphs.png
Normal file
BIN
docs/mockups/seedks-glyphs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Loading…
Add table
Add a link
Reference in a new issue