feat(ui): Material 3 redesign, cover-photo viewer, About screen
Apply a Material 3 seed-green palette and rounded components across the home, inventory, quick-add, quantity picker and variety-detail screens. The full-screen photo viewer can now set any photo as the cover (via attachment sort order) and delete after confirmation. Lot forms and packaging surface as choice chips. Extract About out of Settings into its own screen (app version via package_info_plus, website link). Add es/en strings for the new lot types, presentation, home tagline and About. Update Seedees v2 mockups.
This commit is contained in:
parent
f5c36f2369
commit
42c16c0e3f
24 changed files with 1960 additions and 416 deletions
179
apps/app_seeds/lib/ui/about_screen.dart
Normal file
179
apps/app_seeds/lib/ui/about_screen.dart
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// The app's public website. Shown as a tappable row in [AboutScreen].
|
||||
const String _websiteUrl = 'https://tanemaki.app';
|
||||
|
||||
/// An "about" screen à la Ğ1nkgo: brand header, the human explanation of what
|
||||
/// Tanemaki is and where its name comes from, the app version, license, the
|
||||
/// website, and the bundled open-source licenses page.
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.about.title)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
const _Header(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
|
||||
child: Text(
|
||||
t.about.tagline,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: seedOnSurface,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
const _Paragraph(),
|
||||
const Divider(height: 32),
|
||||
_InfoTile(
|
||||
icon: Icons.info_outline,
|
||||
label: t.about.version,
|
||||
valueBuilder: (context) => const _VersionText(),
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.balance_outlined,
|
||||
label: t.about.license,
|
||||
value: t.about.licenseValue,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.public, color: seedGreen),
|
||||
title: Text(t.about.website),
|
||||
subtitle: const Text(_websiteUrl),
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse(_websiteUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.description_outlined, color: seedGreen),
|
||||
title: Text(t.about.openSourceLicenses),
|
||||
subtitle: Text(t.about.openSourceLicensesSubtitle),
|
||||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: t.app.title,
|
||||
applicationIcon: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Image.asset('assets/logo.png', width: 48),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand block: the logo, the app name and its 種まき kanji.
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset('assets/logo.png', width: 88),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: const TextStyle(
|
||||
color: seedGreen,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.about.kanji,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: seedGreen.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two explanatory paragraphs: what Tanemaki is, and the heritage of its
|
||||
/// name (yui / tanomoshi / the paper Plantare).
|
||||
class _Paragraph extends StatelessWidget {
|
||||
const _Paragraph();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final style = Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: seedOnSurface, height: 1.5);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.about.intro, style: style),
|
||||
const SizedBox(height: 14),
|
||||
Text(t.about.heritage, style: style),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A leading-icon row showing a static [value] or a [valueBuilder] widget.
|
||||
class _InfoTile extends StatelessWidget {
|
||||
const _InfoTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.value,
|
||||
this.valueBuilder,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String? value;
|
||||
final WidgetBuilder? valueBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: seedGreen),
|
||||
title: Text(label),
|
||||
trailing: value != null
|
||||
? Text(value!, style: Theme.of(context).textTheme.bodyMedium)
|
||||
: valueBuilder?.call(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the app version from the platform and renders it as `x.y.z (build)`.
|
||||
class _VersionText extends StatelessWidget {
|
||||
const _VersionText();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (context, snapshot) {
|
||||
final info = snapshot.data;
|
||||
final text = info == null
|
||||
? '—'
|
||||
: '${info.version} (${info.buildNumber})';
|
||||
return Text(text, style: Theme.of(context).textTheme.bodyMedium);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@ import '../i18n/strings.g.dart';
|
|||
import 'seed_glyph.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// The app's navigation drawer (mockup 03). Inventory is live; the social items
|
||||
/// (market, profile, chat…) belong to Block 2 and are shown disabled with a
|
||||
/// "coming soon" tag so the roadmap is visible.
|
||||
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
|
||||
/// the live destination (green seed glyph), the social items (market, profile,
|
||||
/// chat…) belong to Block 2 and are greyed with a "soon" tag, and Settings sits
|
||||
/// pinned at the bottom.
|
||||
class AppDrawer extends StatelessWidget {
|
||||
const AppDrawer({super.key});
|
||||
|
||||
|
|
@ -16,34 +17,42 @@ class AppDrawer extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Drawer(
|
||||
backgroundColor: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _DrawerHeader(),
|
||||
_ActiveItem(
|
||||
icon: const SeedGlyph(SeedGlyphs.jars, size: 22),
|
||||
_DrawerItem(
|
||||
icon: const SeedGlyph(SeedGlyphs.jars, size: 23),
|
||||
label: t.menu.inventory,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.go('/inventory');
|
||||
},
|
||||
),
|
||||
_SoonItem(icon: Symbols.storefront, label: t.menu.market),
|
||||
const Divider(),
|
||||
_SoonItem(icon: Icons.person_outline, label: t.menu.profile),
|
||||
_SoonItem(icon: Icons.chat_bubble_outline, label: t.menu.chat),
|
||||
_SoonItem(icon: Icons.favorite_border, label: t.menu.wishlist),
|
||||
_SoonItem(icon: Icons.group_outlined, label: t.menu.following),
|
||||
_DrawerItem(
|
||||
icon: const Icon(Symbols.storefront),
|
||||
label: t.menu.market,
|
||||
divider: true,
|
||||
),
|
||||
_DrawerItem(icon: const Icon(Icons.person), label: t.menu.profile),
|
||||
_DrawerItem(
|
||||
icon: const Icon(Icons.chat_bubble),
|
||||
label: t.menu.chat,
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: const Icon(Icons.favorite),
|
||||
label: t.menu.wishlist,
|
||||
),
|
||||
_DrawerItem(icon: const Icon(Icons.group), label: t.menu.following),
|
||||
const Spacer(),
|
||||
const Divider(),
|
||||
_ActiveItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
const Divider(height: 1),
|
||||
_DrawerItem(
|
||||
icon: const Icon(Icons.settings),
|
||||
label: t.menu.settings,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.go('/settings');
|
||||
context.push('/settings');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
@ -53,70 +62,110 @@ class AppDrawer extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// The drawer's brand header. Tapping it closes the drawer and returns to the
|
||||
/// home screen.
|
||||
class _DrawerHeader extends StatelessWidget {
|
||||
const _DrawerHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.eco, color: seedGreen, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.t.app.title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: seedGreenDark,
|
||||
fontWeight: FontWeight.bold,
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.go('/');
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(22, 18, 22, 16),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: seedDivider)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset('assets/logo.png', height: 34),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.t.app.title,
|
||||
style: const TextStyle(
|
||||
color: seedGreen,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActiveItem extends StatelessWidget {
|
||||
const _ActiveItem({
|
||||
/// A 52-tall drawer row. A live destination has an [onTap] (dark text, green
|
||||
/// icon); a disabled Block-2 destination is greyed with a "soon" tag. [divider]
|
||||
/// draws a hairline under the row (separating live from upcoming items).
|
||||
class _DrawerItem extends StatelessWidget {
|
||||
const _DrawerItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.onTap,
|
||||
this.divider = false,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
final bool divider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: SizedBox(width: 24, child: Center(child: icon)),
|
||||
title: Text(label),
|
||||
final enabled = onTap != null;
|
||||
final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F);
|
||||
final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F);
|
||||
final row = InkWell(
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A disabled Block-2 destination with a subtle "coming soon" tag.
|
||||
class _SoonItem extends StatelessWidget {
|
||||
const _SoonItem({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
enabled: false,
|
||||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
trailing: Text(
|
||||
context.t.common.comingSoon,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.black38),
|
||||
child: Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
decoration: divider
|
||||
? const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: seedDivider)),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Center(
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(size: 23, color: iconColor),
|
||||
child: icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: 15,
|
||||
fontWeight: enabled ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!enabled)
|
||||
Text(
|
||||
context.t.common.comingSoon.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFB3BDA8),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import 'app_drawer.dart';
|
|||
import 'seed_glyph.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// The main menu (mockup 02): a sprout logo over a faint seed-glyph watermark,
|
||||
/// with the big "open market" (Block 2, coming soon) and "your inventory"
|
||||
/// destinations. The hamburger opens [AppDrawer].
|
||||
/// The main menu (redesign screen 00): a sprout logo in a soft green disc over a
|
||||
/// faint seed-glyph watermark, with "Your inventory" as the primary green call
|
||||
/// to action and "Open market" (Block 2) as a disabled outlined card. The
|
||||
/// hamburger opens [AppDrawer].
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
|
|
@ -25,33 +26,61 @@ class HomeScreen extends StatelessWidget {
|
|||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
height: 140,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
_MenuButton(
|
||||
icon: const SeedGlyph(
|
||||
SeedGlyphs.share,
|
||||
size: 26,
|
||||
color: Colors.white,
|
||||
Center(
|
||||
child: Container(
|
||||
width: 128,
|
||||
height: 128,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFDDF3CE),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
'assets/logo.png',
|
||||
height: 84,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
label: t.home.openMarket,
|
||||
tag: t.common.comingSoon,
|
||||
onTap: null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
t.app.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1A2A13),
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
t.home.tagline,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF55624A),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 34),
|
||||
_PrimaryMenuCard(
|
||||
key: const Key('home.inventory'),
|
||||
icon: Icons.inventory_2_outlined,
|
||||
label: t.home.yourInventory,
|
||||
subtitle: t.home.yourInventorySubtitle,
|
||||
onTap: () => context.go('/inventory'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_MenuButton(
|
||||
key: const Key('home.inventory'),
|
||||
icon: const Icon(Icons.list, color: Colors.white),
|
||||
label: t.home.yourInventory,
|
||||
onTap: () => context.go('/inventory'),
|
||||
_OutlinedMenuCard(
|
||||
icon: Icons.storefront_outlined,
|
||||
label: t.home.openMarket,
|
||||
subtitle: t.home.openMarketSubtitle,
|
||||
tag: t.common.comingSoon,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -64,50 +93,49 @@ class HomeScreen extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _MenuButton extends StatelessWidget {
|
||||
const _MenuButton({
|
||||
/// The primary green call-to-action: white icon in a translucent disc, a title
|
||||
/// and a subtitle, with a soft green shadow.
|
||||
class _PrimaryMenuCard extends StatelessWidget {
|
||||
const _PrimaryMenuCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.tag,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final String? tag;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onTap != null;
|
||||
return Material(
|
||||
color: enabled ? seedGreen : seedGreen.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: seedGreen,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
elevation: 3,
|
||||
shadowColor: seedGreen.withValues(alpha: 0.4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
_IconDisc(
|
||||
background: Colors.white.withValues(alpha: 0.18),
|
||||
child: Icon(icon, color: Colors.white, size: 26),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
child: _CardText(
|
||||
label: label,
|
||||
subtitle: subtitle,
|
||||
color: Colors.white,
|
||||
subtitleColor: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
if (tag != null)
|
||||
Text(
|
||||
tag!,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -116,41 +144,168 @@ class _MenuButton extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// A faint scatter of seed pictograms behind the menu (like mockup 02).
|
||||
/// A disabled Block-2 destination: an outlined white card with a green-disc
|
||||
/// icon and a "soon" tag.
|
||||
class _OutlinedMenuCard extends StatelessWidget {
|
||||
const _OutlinedMenuCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.tag,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final String tag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: const Color(0xFFB8C4AC)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22),
|
||||
child: Row(
|
||||
children: [
|
||||
_IconDisc(
|
||||
background: const Color(0xFFDDF3CE),
|
||||
child: Icon(icon, color: seedGreen, size: 26),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _CardText(
|
||||
label: label,
|
||||
subtitle: subtitle,
|
||||
color: seedOnSurface,
|
||||
subtitleColor: seedMuted,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
tag.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFB3BDA8),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconDisc extends StatelessWidget {
|
||||
const _IconDisc({required this.background, required this.child});
|
||||
|
||||
final Color background;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: background),
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CardText extends StatelessWidget {
|
||||
const _CardText({
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.color,
|
||||
required this.subtitleColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final Color color;
|
||||
final Color subtitleColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(subtitle, style: TextStyle(color: subtitleColor, fontSize: 13)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single seed pictogram scattered onto the watermark: [ax]/[ay] are
|
||||
/// fractional positions (0..1) within the layer, [angle] a slight tilt so the
|
||||
/// scatter feels hand-strewn rather than gridded (like screen 00).
|
||||
class _ScatteredGlyph {
|
||||
const _ScatteredGlyph(this.char, this.ax, this.ay, this.size, this.angle);
|
||||
|
||||
final String char;
|
||||
final double ax;
|
||||
final double ay;
|
||||
final double size;
|
||||
final double angle;
|
||||
}
|
||||
|
||||
/// A faint, disordered scatter of seed pictograms behind the menu (like screen
|
||||
/// 00): glyphs strewn around the edges at staggered positions and tilts,
|
||||
/// positioned by fraction so it holds up at any screen size.
|
||||
class _SeedWatermark extends StatelessWidget {
|
||||
const _SeedWatermark();
|
||||
|
||||
static const _glyphs = [
|
||||
SeedGlyphs.jars,
|
||||
SeedGlyphs.envelope,
|
||||
SeedGlyphs.bigSpoon,
|
||||
SeedGlyphs.jar,
|
||||
SeedGlyphs.sack,
|
||||
SeedGlyphs.scattered,
|
||||
SeedGlyphs.mug,
|
||||
SeedGlyphs.smallSpoon,
|
||||
_ScatteredGlyph(SeedGlyphs.sack, 0.06, 0.14, 62, -0.18),
|
||||
_ScatteredGlyph(SeedGlyphs.envelope, 0.82, 0.09, 48, 0.22),
|
||||
_ScatteredGlyph(SeedGlyphs.scattered, 0.70, 0.30, 44, -0.12),
|
||||
_ScatteredGlyph(SeedGlyphs.jar, 0.16, 0.44, 40, 0.15),
|
||||
_ScatteredGlyph(SeedGlyphs.jars, 0.85, 0.66, 44, 0.18),
|
||||
_ScatteredGlyph(SeedGlyphs.smallSpoon, 0.10, 0.74, 48, -0.22),
|
||||
_ScatteredGlyph(SeedGlyphs.pouring, 0.40, 0.90, 52, 0.10),
|
||||
_ScatteredGlyph(SeedGlyphs.mug, 0.72, 0.88, 60, -0.14),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: 0.06,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Wrap(
|
||||
spacing: 28,
|
||||
runSpacing: 18,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
for (final g in _glyphs)
|
||||
SeedGlyph(g, size: 64, color: seedGreenDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
return Stack(
|
||||
children: [
|
||||
for (final g in _glyphs)
|
||||
Positioned(
|
||||
left: w * g.ax,
|
||||
top: h * g.ay,
|
||||
child: Opacity(
|
||||
opacity: 0.08,
|
||||
child: Transform.rotate(
|
||||
angle: g.angle,
|
||||
child: SeedGlyph(
|
||||
g.char,
|
||||
size: g.size,
|
||||
color: seedGreenDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,12 +43,14 @@ class InventoryListScreen extends StatelessWidget {
|
|||
key: const Key('inventory.search'),
|
||||
decoration: InputDecoration(
|
||||
hintText: t.inventory.searchHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
||||
hintStyle: const TextStyle(color: seedMuted),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
|
|
@ -123,9 +125,10 @@ class _CategoryHeader extends StatelessWidget {
|
|||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: seedGreenDark,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedGreen,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -154,7 +157,7 @@ class _VarietyTile extends StatelessWidget {
|
|||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
color: Colors.black38,
|
||||
color: seedMuted,
|
||||
tooltip: context.t.common.edit,
|
||||
onPressed: open,
|
||||
),
|
||||
|
|
@ -183,8 +186,8 @@ class _Avatar extends StatelessWidget {
|
|||
? '?'
|
||||
: trimmed.substring(0, 1).toUpperCase();
|
||||
return CircleAvatar(
|
||||
backgroundColor: seedGreen.withValues(alpha: 0.15),
|
||||
foregroundColor: seedGreenDark,
|
||||
backgroundColor: seedAvatar,
|
||||
foregroundColor: seedOnAvatar,
|
||||
child: Text(initial),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import '../i18n/strings.g.dart';
|
|||
QuantityKind.plant => (t.unit.plant.singular, t.unit.plant.plural),
|
||||
QuantityKind.pot => (t.unit.pot.singular, t.unit.pot.plural),
|
||||
QuantityKind.tray => (t.unit.tray.singular, t.unit.tray.plural),
|
||||
QuantityKind.seedling => (t.unit.seedling.singular, t.unit.seedling.plural),
|
||||
QuantityKind.tree => (t.unit.tree.singular, t.unit.tree.plural),
|
||||
QuantityKind.cutting => (t.unit.cutting.singular, t.unit.cutting.plural),
|
||||
QuantityKind.grams => (t.unit.grams.singular, t.unit.grams.plural),
|
||||
QuantityKind.count => (t.unit.count.singular, t.unit.count.plural),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../db/enums.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
|
@ -28,17 +27,37 @@ const seedScaleKinds = <QuantityKind>[
|
|||
QuantityKind.bunch,
|
||||
];
|
||||
|
||||
/// Units for a plant/seedling lot.
|
||||
const plantScaleKinds = <QuantityKind>[
|
||||
QuantityKind.plant,
|
||||
QuantityKind.pot,
|
||||
QuantityKind.tray,
|
||||
];
|
||||
/// The amount units offered for each lot form. Living forms are counted as
|
||||
/// whole individuals (their packaging is a separate [Presentation]); [bulb] also
|
||||
/// allows grams. [pot]/[tray] are intentionally not offered anymore.
|
||||
List<QuantityKind> kindsForType(LotType type) => switch (type) {
|
||||
LotType.seed => seedScaleKinds,
|
||||
LotType.seedling => const [QuantityKind.seedling],
|
||||
LotType.plant => const [QuantityKind.plant],
|
||||
LotType.tree => const [QuantityKind.tree],
|
||||
LotType.bulb => const [
|
||||
QuantityKind.bulb,
|
||||
QuantityKind.tuber,
|
||||
QuantityKind.grams,
|
||||
],
|
||||
LotType.cutting => const [QuantityKind.cutting],
|
||||
};
|
||||
|
||||
List<QuantityKind> kindsForType(LotType type) =>
|
||||
type == LotType.seed ? seedScaleKinds : plantScaleKinds;
|
||||
/// The human label for a lot form.
|
||||
String lotTypeLabel(Translations t, LotType type) => switch (type) {
|
||||
LotType.seed => t.lotType.seed,
|
||||
LotType.seedling => t.lotType.seedling,
|
||||
LotType.plant => t.lotType.plant,
|
||||
LotType.tree => t.lotType.tree,
|
||||
LotType.bulb => t.lotType.bulb,
|
||||
LotType.cutting => t.lotType.cutting,
|
||||
};
|
||||
|
||||
/// Seed vs plant/seedling segmented selector.
|
||||
/// Living forms whose packaging ([Presentation]) is worth asking about.
|
||||
bool typeHasPresentation(LotType type) =>
|
||||
type == LotType.seedling || type == LotType.plant || type == LotType.tree;
|
||||
|
||||
/// Form selector: a wrap of choice chips, one per [LotType].
|
||||
class LotTypeSelector extends StatelessWidget {
|
||||
const LotTypeSelector({
|
||||
required this.value,
|
||||
|
|
@ -52,26 +71,65 @@ class LotTypeSelector extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return SegmentedButton<LotType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: LotType.seed,
|
||||
label: Text(t.lotType.seed),
|
||||
icon: const SeedGlyph(SeedGlyphs.jars, size: 18),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: LotType.plant,
|
||||
label: Text(t.lotType.plant),
|
||||
icon: const Icon(Symbols.potted_plant, size: 18),
|
||||
),
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final type in LotType.values)
|
||||
ChoiceChip(
|
||||
key: Key('lotType.${type.name}'),
|
||||
selected: value == type,
|
||||
onSelected: (_) => onChanged(type),
|
||||
avatar: type == LotType.seed
|
||||
? const SeedGlyph(SeedGlyphs.jars, size: 18)
|
||||
: Icon(iconForLotType(type), size: 18),
|
||||
label: Text(lotTypeLabel(t, type)),
|
||||
),
|
||||
],
|
||||
selected: {value},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (s) => onChanged(s.first),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional packaging selector for living lots. Tapping the selected chip again
|
||||
/// clears it (packaging is optional).
|
||||
class PresentationSelector extends StatelessWidget {
|
||||
const PresentationSelector({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Presentation? value;
|
||||
final ValueChanged<Presentation?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final p in Presentation.values)
|
||||
ChoiceChip(
|
||||
key: Key('presentation.${p.name}'),
|
||||
selected: value == p,
|
||||
onSelected: (sel) => onChanged(sel ? p : null),
|
||||
label: Text(presentationLabel(t, p)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The human label for a packaging [Presentation].
|
||||
String presentationLabel(Translations t, Presentation p) => switch (p) {
|
||||
Presentation.pot => t.presentation.pot,
|
||||
Presentation.tray => t.presentation.tray,
|
||||
Presentation.plug => t.presentation.plug,
|
||||
Presentation.bareRoot => t.presentation.bareRoot,
|
||||
Presentation.rootBall => t.presentation.rootBall,
|
||||
};
|
||||
|
||||
/// A horizontal scale of large unit icons + an optional number stepper. Emits a
|
||||
/// [Quantity] (or null while nothing is picked) via [onChanged].
|
||||
class QuantityPicker extends StatefulWidget {
|
||||
|
|
|
|||
|
|
@ -39,77 +39,81 @@ class QuickAddSheet extends StatelessWidget {
|
|||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
t.quickAdd.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('quickAdd.labelField'),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.quickAdd.labelField,
|
||||
errorText: state.showLabelError
|
||||
? t.quickAdd.labelRequired
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
t.quickAdd.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
onChanged: cubit.labelChanged,
|
||||
onSubmitted: (_) => cubit.submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LotTypeSelector(
|
||||
value: state.lotType,
|
||||
onChanged: (type) {
|
||||
cubit
|
||||
..setLotType(type)
|
||||
..setQuantity(null);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
t.quickAdd.quantity,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
QuantityPicker(
|
||||
type: state.lotType,
|
||||
value: state.quantity,
|
||||
onChanged: cubit.setQuantity,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: cubit.toggleExpanded,
|
||||
icon: Icon(
|
||||
state.expanded ? Icons.expand_less : Icons.expand_more,
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('quickAdd.labelField'),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.quickAdd.labelField,
|
||||
errorText: state.showLabelError
|
||||
? t.quickAdd.labelRequired
|
||||
: null,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
label: Text(t.quickAdd.more),
|
||||
onChanged: cubit.labelChanged,
|
||||
onSubmitted: (_) => cubit.submit(),
|
||||
),
|
||||
),
|
||||
if (state.expanded) const _MoreSection(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(t.quickAdd.cancel),
|
||||
const SizedBox(height: 16),
|
||||
LotTypeSelector(
|
||||
value: state.lotType,
|
||||
onChanged: (type) {
|
||||
cubit
|
||||
..setLotType(type)
|
||||
..setQuantity(null);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
t.quickAdd.quantity,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
QuantityPicker(
|
||||
type: state.lotType,
|
||||
value: state.quantity,
|
||||
onChanged: cubit.setQuantity,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: cubit.toggleExpanded,
|
||||
icon: Icon(
|
||||
state.expanded ? Icons.expand_less : Icons.expand_more,
|
||||
),
|
||||
label: Text(t.quickAdd.more),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
key: const Key('quickAdd.save'),
|
||||
onPressed: state.submitting ? null : cubit.submit,
|
||||
child: Text(t.quickAdd.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.expanded) const _MoreSection(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(t.quickAdd.cancel),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
key: const Key('quickAdd.save'),
|
||||
onPressed: state.submitting ? null : cubit.submit,
|
||||
child: Text(t.quickAdd.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'package:commons_core/commons_core.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../db/enums.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.
|
||||
|
|
@ -61,18 +63,36 @@ String? seedGlyphForKind(QuantityKind kind) => switch (kind) {
|
|||
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.plant => Symbols.potted_plant,
|
||||
QuantityKind.pot => Icons.yard_outlined,
|
||||
QuantityKind.tray => Icons.grid_on_outlined,
|
||||
QuantityKind.bulb ||
|
||||
QuantityKind.tuber ||
|
||||
QuantityKind.bunch => Icons.spa_outlined,
|
||||
// Seed/fruit forms — each a distinct icon so they're told apart at a glance.
|
||||
QuantityKind.cob => Symbols.grass, // mazorca
|
||||
QuantityKind.ear => Symbols.grain, // espiga
|
||||
QuantityKind.head => Symbols.filter_vintage, // cabezuela
|
||||
QuantityKind.seedHead => Symbols.spa,
|
||||
QuantityKind.fruit => Symbols.nutrition, // fruto (apple)
|
||||
QuantityKind.bulb => Symbols.spa, // bulbo
|
||||
QuantityKind.tuber => Symbols.egg, // tubérculo
|
||||
QuantityKind.bunch => Symbols.local_florist, // manojo
|
||||
// Living material counted as individuals.
|
||||
QuantityKind.seedling => Symbols.potted_plant,
|
||||
QuantityKind.plant => Symbols.yard,
|
||||
QuantityKind.tree => Symbols.park,
|
||||
QuantityKind.cutting => Symbols.content_cut,
|
||||
QuantityKind.pot => Symbols.yard,
|
||||
QuantityKind.tray => Symbols.grid_on,
|
||||
_ => Icons.eco_outlined,
|
||||
};
|
||||
|
||||
/// The icon for a lot's [LotType] form (used in the form selector and lot chip),
|
||||
/// distinct across all forms so the selector reads clearly.
|
||||
IconData iconForLotType(LotType type) => switch (type) {
|
||||
LotType.seed => Symbols.eco, // overridden by a seed glyph where shown
|
||||
LotType.seedling => Symbols.potted_plant,
|
||||
LotType.plant => Symbols.local_florist,
|
||||
LotType.tree => Symbols.park,
|
||||
LotType.bulb => Symbols.spa,
|
||||
LotType.cutting => Symbols.content_cut,
|
||||
};
|
||||
|
||||
/// A consistent icon for a [QuantityKind]: the seed glyph where one exists,
|
||||
/// otherwise a Material fallback.
|
||||
class QuantityKindIcon extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'theme.dart';
|
||||
|
|
@ -35,8 +36,10 @@ class SettingsScreen extends StatelessWidget {
|
|||
_SectionHeader(t.settings.about),
|
||||
ListTile(
|
||||
leading: Image.asset('assets/logo.png', width: 32),
|
||||
title: Text(t.app.title),
|
||||
title: Text(t.settings.aboutOpen),
|
||||
subtitle: Text(t.settings.aboutText),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/about'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -56,8 +59,9 @@ class _SectionHeader extends StatelessWidget {
|
|||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: seedGreenDark,
|
||||
color: seedGreen,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,35 +1,104 @@
|
|||
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);
|
||||
/// Material 3 palette from the Seedees v2 redesign
|
||||
/// (docs/mockups/seedees_material3.html): green app bars over a pale-green
|
||||
/// canvas, a saturated green for primary actions (FAB, buttons, links, category
|
||||
/// headers) and a soft green container for selected/tonal surfaces.
|
||||
const seedGreen = Color(0xFF2F7D34); // primary (actions, links, headers)
|
||||
const seedGreenDark = Color(0xFF22521E); // status bar / darker green
|
||||
const seedAppBar = Color(0xFF3E8C38); // app-bar green
|
||||
const seedCanvas = Color(0xFFE7F1E0); // surface / scaffold
|
||||
const seedPrimaryContainer = Color(0xFFD3E8C8); // tonal green container
|
||||
const seedOnPrimaryContainer = Color(0xFF2F6D2A);
|
||||
const seedField = Color(0xFFF3F7EE); // outlined field fill
|
||||
const seedAvatar = Color(0xFFCFE7C3); // list avatar disc
|
||||
const seedOnAvatar = Color(0xFF3B7A34);
|
||||
const seedOutline = Color(0xFFC3CBB6);
|
||||
const seedDivider = Color(0xFFE4E9DD);
|
||||
const seedTitle = Color(0xFF1B2416); // heading text on canvas
|
||||
const seedOnSurface = Color(0xFF1B2416);
|
||||
const seedOnSurfaceVariant = Color(0xFF5C6B52); // scientific names, secondary
|
||||
const seedMuted = Color(0xFF8A9880); // hints, list edit icon
|
||||
const seedRating = Color(0xFFE8A200); // stars
|
||||
|
||||
ThemeData buildTaneTheme() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seedGreen,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
final scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: seedGreen,
|
||||
brightness: Brightness.light,
|
||||
).copyWith(
|
||||
primary: seedGreen,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: seedPrimaryContainer,
|
||||
onPrimaryContainer: seedOnPrimaryContainer,
|
||||
secondaryContainer: seedPrimaryContainer,
|
||||
onSecondaryContainer: seedOnPrimaryContainer,
|
||||
tertiary: seedRating,
|
||||
onTertiary: Colors.white,
|
||||
surface: seedCanvas,
|
||||
onSurface: seedOnSurface,
|
||||
surfaceContainerHighest: seedField,
|
||||
onSurfaceVariant: seedOnSurfaceVariant,
|
||||
outline: seedMuted,
|
||||
outlineVariant: seedOutline,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: seedCanvas,
|
||||
// Green app bars — aligned with the v2 mockups and the real app.
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: seedGreen,
|
||||
backgroundColor: seedAppBar,
|
||||
foregroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
iconTheme: IconThemeData(color: Colors.white),
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
// Squircle FAB (20px radius) as in the mockups, in the saturated primary.
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: seedGreen,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
drawerTheme: const DrawerThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
backgroundColor: seedCanvas,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(26)),
|
||||
),
|
||||
),
|
||||
// Pill "Save" actions across sheets and dialogs, as in the v3 mockups.
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: seedGreen,
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 13),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(foregroundColor: seedGreen),
|
||||
),
|
||||
// Green floating labels on the edit-seed / add-batch text fields. (Borders
|
||||
// are left to each field so the inventory search keeps its borderless pill.)
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
floatingLabelStyle: TextStyle(color: seedGreen),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
space: 1,
|
||||
thickness: 1,
|
||||
color: seedDivider,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(space: 1, thickness: 1),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../data/species_repository.dart';
|
||||
|
|
@ -198,6 +197,7 @@ String _lotSubtitle(Translations t, VarietyLot lot) {
|
|||
else
|
||||
t.detail.noYear,
|
||||
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
|
||||
if (lot.presentation != null) presentationLabel(t, lot.presentation!),
|
||||
];
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
|
@ -214,8 +214,9 @@ class _LotTypeChip extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final isPlant = type == LotType.plant;
|
||||
final color = isPlant ? const Color(0xFF00796B) : seedGreenDark;
|
||||
final isSeed = type == LotType.seed;
|
||||
// Seed lots read green; living/vegetative forms read teal.
|
||||
final color = isSeed ? seedGreenDark : const Color(0xFF00796B);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -225,13 +226,13 @@ class _LotTypeChip extends StatelessWidget {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPlant)
|
||||
Icon(Symbols.potted_plant, size: 14, color: color)
|
||||
if (isSeed)
|
||||
SeedGlyph(SeedGlyphs.jars, size: 14, color: color)
|
||||
else
|
||||
SeedGlyph(SeedGlyphs.jars, size: 14, color: color),
|
||||
Icon(iconForLotType(type), size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isPlant ? t.lotType.plant : t.lotType.seed,
|
||||
lotTypeLabel(t, type),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
|
|
@ -323,7 +324,9 @@ Future<void> _showGerminationSheet(
|
|||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.germination.germinated,
|
||||
border: const OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -335,7 +338,9 @@ Future<void> _showGerminationSheet(
|
|||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.germination.sampleSize,
|
||||
border: const OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -504,7 +509,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
controller: _name,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.editVariety.name,
|
||||
border: const OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -515,7 +522,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
labelText: t.editVariety.species,
|
||||
hintText: t.editVariety.speciesHint,
|
||||
prefixIcon: const Icon(Icons.eco_outlined),
|
||||
border: const OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
onChanged: _onSpeciesQuery,
|
||||
),
|
||||
|
|
@ -532,7 +541,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
controller: _category,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.editVariety.category,
|
||||
border: const OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -542,7 +553,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.editVariety.notes,
|
||||
border: const OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
|
@ -577,6 +590,7 @@ Future<void> _showLotSheet(
|
|||
var selectedMonth = existing?.harvestMonth;
|
||||
var selectedType = existing?.type ?? LotType.seed;
|
||||
var selectedQuantity = existing?.quantity;
|
||||
var selectedPresentation = existing?.presentation;
|
||||
final editing = existing != null;
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
|
|
@ -593,58 +607,84 @@ Future<void> _showLotSheet(
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
editing ? t.detail.editLot : t.addLot.title,
|
||||
style: Theme.of(sheetContext).textTheme.titleLarge,
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
editing ? t.detail.editLot : t.addLot.title,
|
||||
style: Theme.of(sheetContext).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
if (editing)
|
||||
IconButton(
|
||||
key: const Key('lot.delete'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: t.common.delete,
|
||||
onPressed: () {
|
||||
cubit.deleteLot(existing.id);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LotTypeSelector(
|
||||
value: selectedType,
|
||||
onChanged: (type) => setState(() {
|
||||
selectedType = type;
|
||||
selectedQuantity = null;
|
||||
if (!typeHasPresentation(type)) {
|
||||
selectedPresentation = null;
|
||||
}
|
||||
}),
|
||||
),
|
||||
if (typeHasPresentation(selectedType)) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.presentation.title,
|
||||
style: Theme.of(sheetContext).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PresentationSelector(
|
||||
value: selectedPresentation,
|
||||
onChanged: (p) =>
|
||||
setState(() => selectedPresentation = p),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
t.addLot.year,
|
||||
style: Theme.of(sheetContext).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
HarvestDateField(
|
||||
year: selectedYear,
|
||||
month: selectedMonth,
|
||||
onChanged: (year, month) => setState(() {
|
||||
selectedYear = year;
|
||||
selectedMonth = month;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.addLot.quantity,
|
||||
style: Theme.of(sheetContext).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
QuantityPicker(
|
||||
type: selectedType,
|
||||
value: selectedQuantity,
|
||||
onChanged: (q) => selectedQuantity = q,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (editing)
|
||||
IconButton(
|
||||
key: const Key('lot.delete'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: t.common.delete,
|
||||
onPressed: () {
|
||||
cubit.deleteLot(existing.id);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LotTypeSelector(
|
||||
value: selectedType,
|
||||
onChanged: (type) => setState(() {
|
||||
selectedType = type;
|
||||
selectedQuantity = null;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
t.addLot.year,
|
||||
style: Theme.of(sheetContext).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
HarvestDateField(
|
||||
year: selectedYear,
|
||||
month: selectedMonth,
|
||||
onChanged: (year, month) => setState(() {
|
||||
selectedYear = year;
|
||||
selectedMonth = month;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.addLot.quantity,
|
||||
style: Theme.of(sheetContext).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
QuantityPicker(
|
||||
type: selectedType,
|
||||
value: selectedQuantity,
|
||||
onChanged: (q) => selectedQuantity = q,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
|
|
@ -664,6 +704,7 @@ Future<void> _showLotSheet(
|
|||
year: selectedYear,
|
||||
month: selectedMonth,
|
||||
quantity: selectedQuantity,
|
||||
presentation: selectedPresentation,
|
||||
);
|
||||
} else {
|
||||
cubit.addLot(
|
||||
|
|
@ -671,6 +712,7 @@ Future<void> _showLotSheet(
|
|||
year: selectedYear,
|
||||
month: selectedMonth,
|
||||
quantity: selectedQuantity,
|
||||
presentation: selectedPresentation,
|
||||
);
|
||||
}
|
||||
Navigator.of(sheetContext).pop();
|
||||
|
|
@ -807,12 +849,25 @@ class _DetailHeader extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (detail.category != null)
|
||||
Text(
|
||||
detail.category!,
|
||||
style: const TextStyle(
|
||||
color: seedLink,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.category_outlined,
|
||||
size: 17,
|
||||
color: seedGreen,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
detail.category!,
|
||||
style: const TextStyle(
|
||||
color: seedGreen,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (detail.scientificName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
|
|
@ -890,37 +945,27 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Tap a photo to open the full-screen viewer, where you can set the
|
||||
// cover or delete it. The first photo is the cover.
|
||||
SizedBox(
|
||||
height: 140,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: photos.length,
|
||||
onPageChanged: (i) => setState(() => _page = i),
|
||||
itemBuilder: (_, i) => GestureDetector(
|
||||
onTap: () => _openPhotoViewer(context, photos, i),
|
||||
child: Image.memory(
|
||||
photos[i].bytes,
|
||||
width: 140,
|
||||
height: 140,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: photos.length,
|
||||
onPageChanged: (i) => setState(() => _page = i),
|
||||
itemBuilder: (_, i) => GestureDetector(
|
||||
key: Key('photo.thumb.$i'),
|
||||
onTap: () => _openPhotoViewer(context, cubit, i),
|
||||
child: Image.memory(
|
||||
photos[i].bytes,
|
||||
width: 140,
|
||||
height: 140,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 2,
|
||||
right: 2,
|
||||
child: _CircleIconButton(
|
||||
icon: Icons.close,
|
||||
tooltip: context.t.common.delete,
|
||||
onPressed: () => cubit.removePhoto(photos[page].id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
|
@ -946,33 +991,6 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
|
|||
}
|
||||
}
|
||||
|
||||
class _CircleIconButton extends StatelessWidget {
|
||||
const _CircleIconButton({
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.black45,
|
||||
shape: const CircleBorder(),
|
||||
child: IconButton(
|
||||
key: const Key('photo.delete'),
|
||||
icon: Icon(icon, size: 18, color: Colors.white),
|
||||
tooltip: tooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Dots extends StatelessWidget {
|
||||
const _Dots({required this.count, required this.active, this.onTap});
|
||||
|
||||
|
|
@ -1009,38 +1027,141 @@ class _Dots extends StatelessWidget {
|
|||
|
||||
void _openPhotoViewer(
|
||||
BuildContext context,
|
||||
List<VarietyPhoto> photos,
|
||||
VarietyDetailCubit cubit,
|
||||
int initialIndex,
|
||||
) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => _PhotoViewer(photos: photos, initialIndex: initialIndex),
|
||||
// Re-provide the cubit so the pushed route can set the cover / delete and
|
||||
// rebuild live as the photo list reorders or shrinks.
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: cubit,
|
||||
child: _PhotoViewer(initialIndex: initialIndex),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Full-screen, swipeable, pinch-to-zoom photo viewer.
|
||||
class _PhotoViewer extends StatelessWidget {
|
||||
const _PhotoViewer({required this.photos, required this.initialIndex});
|
||||
/// Full-screen, swipeable, pinch-to-zoom photo viewer. Its app bar hosts the
|
||||
/// per-photo actions (set as cover, delete) — the thumbnail gallery only opens
|
||||
/// it. Stays in sync with the cubit, so deleting the last photo closes it.
|
||||
class _PhotoViewer extends StatefulWidget {
|
||||
const _PhotoViewer({required this.initialIndex});
|
||||
|
||||
final List<VarietyPhoto> photos;
|
||||
final int initialIndex;
|
||||
|
||||
@override
|
||||
State<_PhotoViewer> createState() => _PhotoViewerState();
|
||||
}
|
||||
|
||||
class _PhotoViewerState extends State<_PhotoViewer> {
|
||||
late final PageController _controller = PageController(
|
||||
initialPage: widget.initialIndex,
|
||||
);
|
||||
late int _page = widget.initialIndex;
|
||||
bool _popped = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
VarietyDetailCubit cubit,
|
||||
VarietyPhoto photo,
|
||||
) async {
|
||||
final t = context.t;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
content: Text(t.photo.deleteConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('photo.deleteConfirm'),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(t.common.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed ?? false) await cubit.removePhoto(photo.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: PageView.builder(
|
||||
controller: PageController(initialPage: initialIndex),
|
||||
itemCount: photos.length,
|
||||
itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes),
|
||||
),
|
||||
final cubit = context.read<VarietyDetailCubit>();
|
||||
return BlocBuilder<VarietyDetailCubit, VarietyDetailState>(
|
||||
builder: (context, state) {
|
||||
final photos = state.detail?.photos ?? const <VarietyPhoto>[];
|
||||
// Nothing left to show (last photo deleted / variety gone): close once.
|
||||
// The guard stops the post-frame callback re-arming every rebuild.
|
||||
if (photos.isEmpty) {
|
||||
if (!_popped) {
|
||||
_popped = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
});
|
||||
}
|
||||
return const Scaffold(backgroundColor: Colors.black);
|
||||
}
|
||||
final page = _page.clamp(0, photos.length - 1);
|
||||
// A delete can leave the pager past the end: snap it back. jumpToPage
|
||||
// fires onPageChanged, which updates _page — so no setState here (that
|
||||
// would re-arm the callback and spin the frame loop).
|
||||
if (page != _page && _controller.hasClients) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _controller.hasClients) _controller.jumpToPage(page);
|
||||
});
|
||||
}
|
||||
final isCover = page == 0;
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (photos.length > 1)
|
||||
IconButton(
|
||||
key: const Key('photo.cover'),
|
||||
icon: Icon(isCover ? Icons.star : Icons.star_border),
|
||||
tooltip: isCover
|
||||
? context.t.photo.isCover
|
||||
: context.t.photo.setAsCover,
|
||||
onPressed: isCover
|
||||
? null
|
||||
: () async {
|
||||
await cubit.setPreferredPhoto(photos[page].id);
|
||||
// The chosen photo is now the cover (index 0): follow
|
||||
// it there so the filled star reflects the change.
|
||||
if (mounted && _controller.hasClients) {
|
||||
_controller.jumpToPage(0);
|
||||
setState(() => _page = 0);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('photo.delete'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: context.t.common.delete,
|
||||
onPressed: () => _confirmDelete(cubit, photos[page]),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: photos.length,
|
||||
onPageChanged: (i) => setState(() => _page = i),
|
||||
itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue