From c789cd1027935f1a07d4b0c7de70a194797d28a9 Mon Sep 17 00:00:00 2001 From: vjrj Date: Wed, 8 Jul 2026 14:15:13 +0200 Subject: [PATCH] feat(photos): camera-or-gallery picker + swipeable full-screen viewer - Adding a photo (quick-add and detail) now offers a Camera / Gallery chooser (shared pickPhoto helper); camera errors on desktop degrade gracefully. - Full-screen viewer: pinch-to-zoom pans only while zoomed, so a plain horizontal swipe pages between photos again. 54 tests green; Linux runs. --- apps/app_seeds/lib/i18n/en.i18n.json | 4 ++ apps/app_seeds/lib/i18n/es.i18n.json | 4 ++ apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 18 +++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 14 +++++ apps/app_seeds/lib/ui/photo_pick.dart | 46 ++++++++++++++++ apps/app_seeds/lib/ui/quick_add_sheet.dart | 32 +++-------- .../lib/ui/variety_detail_screen.dart | 54 ++++++++++++++----- 8 files changed, 136 insertions(+), 40 deletions(-) create mode 100644 apps/app_seeds/lib/ui/photo_pick.dart diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 67f7e51..1493db7 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -14,6 +14,10 @@ "openMarket": "Open market", "yourInventory": "Your inventory" }, + "photo": { + "camera": "Take a photo", + "gallery": "Choose from gallery" + }, "menu": { "inventory": "Inventory", "market": "Market", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 87cc7dd..69e0d2e 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -14,6 +14,10 @@ "openMarket": "Mercado abierto", "yourInventory": "Tu inventario" }, + "photo": { + "camera": "Hacer una foto", + "gallery": "Elegir de la galería" + }, "menu": { "inventory": "Inventario", "market": "Mercado", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index abe1c4b..095cc16 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 256 (128 per locale) +/// Strings: 260 (130 per locale) /// -/// Built on 2026-07-08 at 12:01 UTC +/// Built on 2026-07-08 at 12:10 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 80e2540..e11c75a 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -43,6 +43,7 @@ class Translations with BaseTranslations { late final Translations$app$en app = Translations$app$en.internal(_root); late final Translations$common$en common = Translations$common$en.internal(_root); late final Translations$home$en home = Translations$home$en.internal(_root); + late final Translations$photo$en photo = Translations$photo$en.internal(_root); late final Translations$menu$en menu = Translations$menu$en.internal(_root); late final Translations$settings$en settings = Translations$settings$en.internal(_root); late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root); @@ -110,6 +111,21 @@ class Translations$home$en { String get yourInventory => 'Your inventory'; } +// Path: photo +class Translations$photo$en { + Translations$photo$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Take a photo' + String get camera => 'Take a photo'; + + /// en: 'Choose from gallery' + String get gallery => 'Choose from gallery'; +} + // Path: menu class Translations$menu$en { Translations$menu$en.internal(this._root); @@ -773,6 +789,8 @@ extension on Translations { 'common.comingSoon' => 'Coming soon', 'home.openMarket' => 'Open market', 'home.yourInventory' => 'Your inventory', + 'photo.camera' => 'Take a photo', + 'photo.gallery' => 'Choose from gallery', 'menu.inventory' => 'Inventory', 'menu.market' => 'Market', 'menu.profile' => 'Your profile', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 1591189..602737b 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -42,6 +42,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Tu inventario'; } +// Path: photo +class _Translations$photo$es extends Translations$photo$en { + _Translations$photo$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get camera => 'Hacer una foto'; + @override String get gallery => 'Elegir de la galería'; +} + // Path: menu class _Translations$menu$es extends Translations$menu$en { _Translations$menu$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -538,6 +550,8 @@ extension on TranslationsEs { 'common.comingSoon' => 'Pronto', 'home.openMarket' => 'Mercado abierto', 'home.yourInventory' => 'Tu inventario', + 'photo.camera' => 'Hacer una foto', + 'photo.gallery' => 'Elegir de la galería', 'menu.inventory' => 'Inventario', 'menu.market' => 'Mercado', 'menu.profile' => 'Tu perfil', diff --git a/apps/app_seeds/lib/ui/photo_pick.dart b/apps/app_seeds/lib/ui/photo_pick.dart new file mode 100644 index 0000000..6e57cb2 --- /dev/null +++ b/apps/app_seeds/lib/ui/photo_pick.dart @@ -0,0 +1,46 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../i18n/strings.g.dart'; + +/// Asks the user to take a photo or pick one from the gallery, then returns its +/// bytes (or null if cancelled/unavailable). Camera failures — e.g. on desktop, +/// which has no camera — are swallowed and yield null. +Future pickPhoto(BuildContext context) async { + final t = context.t; + final source = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('photo.source.camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: Text(t.photo.camera), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), + ListTile( + key: const Key('photo.source.gallery'), + leading: const Icon(Icons.photo_library_outlined), + title: Text(t.photo.gallery), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery), + ), + ], + ), + ), + ); + if (source == null) return null; + try { + final file = await ImagePicker().pickImage( + source: source, + maxWidth: 1280, + imageQuality: 80, + ); + return file?.readAsBytes(); + } on Object { + return null; + } +} diff --git a/apps/app_seeds/lib/ui/quick_add_sheet.dart b/apps/app_seeds/lib/ui/quick_add_sheet.dart index 922e0f0..4d27494 100644 --- a/apps/app_seeds/lib/ui/quick_add_sheet.dart +++ b/apps/app_seeds/lib/ui/quick_add_sheet.dart @@ -1,46 +1,28 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:image_picker/image_picker.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../state/quick_add_cubit.dart'; +import 'photo_pick.dart'; import 'quantity_picker.dart'; -/// Picks a photo and returns its bytes (or null if cancelled). Injected so -/// widget tests can supply a fake instead of the camera plugin. -typedef PhotoPicker = Future Function(); - Future showQuickAddSheet( BuildContext context, { required VarietyRepository repository, - PhotoPicker? photoPicker, }) { return showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => BlocProvider( create: (_) => QuickAddCubit(repository), - child: QuickAddSheet(photoPicker: photoPicker ?? _cameraPhotoPicker), + child: const QuickAddSheet(), ), ); } -Future _cameraPhotoPicker() async { - final file = await ImagePicker().pickImage( - source: ImageSource.camera, - maxWidth: 1280, - imageQuality: 80, - ); - return file?.readAsBytes(); -} - class QuickAddSheet extends StatelessWidget { - const QuickAddSheet({required this.photoPicker, super.key}); - - final PhotoPicker photoPicker; + const QuickAddSheet({super.key}); @override Widget build(BuildContext context) { @@ -111,7 +93,7 @@ class QuickAddSheet extends StatelessWidget { label: Text(t.quickAdd.more), ), ), - if (state.expanded) _MoreSection(photoPicker: photoPicker), + if (state.expanded) const _MoreSection(), const SizedBox(height: 8), Row( children: [ @@ -136,9 +118,7 @@ class QuickAddSheet extends StatelessWidget { } class _MoreSection extends StatelessWidget { - const _MoreSection({required this.photoPicker}); - - final PhotoPicker photoPicker; + const _MoreSection(); @override Widget build(BuildContext context) { @@ -162,7 +142,7 @@ class _MoreSection extends StatelessWidget { alignment: Alignment.centerLeft, child: OutlinedButton.icon( onPressed: () async { - final bytes = await photoPicker(); + final bytes = await pickPhoto(context); if (bytes != null) cubit.photoPicked(bytes); }, icon: const Icon(Icons.photo_camera_outlined), diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 9afc8ab..8dc95e9 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -1,6 +1,7 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -10,6 +11,7 @@ import '../db/enums.dart'; import '../i18n/strings.g.dart'; import '../state/variety_detail_cubit.dart'; import 'harvest_date_picker.dart'; +import 'photo_pick.dart'; import 'quantity_kind_l10n.dart'; import 'quantity_picker.dart'; import 'seed_glyph.dart'; @@ -854,12 +856,7 @@ class _PhotoGalleryState extends State<_PhotoGallery> { } Future _addPhoto(VarietyDetailCubit cubit) async { - final file = await ImagePicker().pickImage( - source: ImageSource.gallery, - maxWidth: 1280, - imageQuality: 80, - ); - final bytes = await file?.readAsBytes(); + final bytes = await pickPhoto(context); if (bytes != null) await cubit.addPhoto(bytes); } @@ -1042,16 +1039,49 @@ class _PhotoViewer extends StatelessWidget { body: PageView.builder( controller: PageController(initialPage: initialIndex), itemCount: photos.length, - itemBuilder: (_, i) => InteractiveViewer( - minScale: 1, - maxScale: 5, - child: Center(child: Image.memory(photos[i].bytes)), - ), + itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes), ), ); } } +/// Pinch-to-zoom photo that only pans while zoomed, so a plain horizontal +/// swipe still pages the surrounding [PageView]. +class _ZoomablePhoto extends StatefulWidget { + const _ZoomablePhoto({required this.bytes}); + + final Uint8List bytes; + + @override + State<_ZoomablePhoto> createState() => _ZoomablePhotoState(); +} + +class _ZoomablePhotoState extends State<_ZoomablePhoto> { + final _controller = TransformationController(); + bool _zoomed = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InteractiveViewer( + transformationController: _controller, + minScale: 1, + maxScale: 5, + panEnabled: _zoomed, + onInteractionEnd: (_) { + final zoomed = _controller.value.getMaxScaleOnAxis() > 1.01; + if (zoomed != _zoomed) setState(() => _zoomed = zoomed); + }, + child: Center(child: Image.memory(widget.bytes)), + ); + } +} + class _SectionTitle extends StatelessWidget { const _SectionTitle(this.text);