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.
This commit is contained in:
vjrj 2026-07-08 14:15:13 +02:00
parent 4b209d6752
commit c789cd1027
8 changed files with 136 additions and 40 deletions

View file

@ -14,6 +14,10 @@
"openMarket": "Open market", "openMarket": "Open market",
"yourInventory": "Your inventory" "yourInventory": "Your inventory"
}, },
"photo": {
"camera": "Take a photo",
"gallery": "Choose from gallery"
},
"menu": { "menu": {
"inventory": "Inventory", "inventory": "Inventory",
"market": "Market", "market": "Market",

View file

@ -14,6 +14,10 @@
"openMarket": "Mercado abierto", "openMarket": "Mercado abierto",
"yourInventory": "Tu inventario" "yourInventory": "Tu inventario"
}, },
"photo": {
"camera": "Hacer una foto",
"gallery": "Elegir de la galería"
},
"menu": { "menu": {
"inventory": "Inventario", "inventory": "Inventario",
"market": "Mercado", "market": "Mercado",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 2 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -43,6 +43,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$app$en app = Translations$app$en.internal(_root); 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$common$en common = Translations$common$en.internal(_root);
late final Translations$home$en home = Translations$home$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$menu$en menu = Translations$menu$en.internal(_root);
late final Translations$settings$en settings = Translations$settings$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); late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root);
@ -110,6 +111,21 @@ class Translations$home$en {
String get yourInventory => 'Your inventory'; 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 // Path: menu
class Translations$menu$en { class Translations$menu$en {
Translations$menu$en.internal(this._root); Translations$menu$en.internal(this._root);
@ -773,6 +789,8 @@ extension on Translations {
'common.comingSoon' => 'Coming soon', 'common.comingSoon' => 'Coming soon',
'home.openMarket' => 'Open market', 'home.openMarket' => 'Open market',
'home.yourInventory' => 'Your inventory', 'home.yourInventory' => 'Your inventory',
'photo.camera' => 'Take a photo',
'photo.gallery' => 'Choose from gallery',
'menu.inventory' => 'Inventory', 'menu.inventory' => 'Inventory',
'menu.market' => 'Market', 'menu.market' => 'Market',
'menu.profile' => 'Your profile', 'menu.profile' => 'Your profile',

View file

@ -42,6 +42,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$app$es app = _Translations$app$es._(_root); @override late final _Translations$app$es app = _Translations$app$es._(_root);
@override late final _Translations$common$es common = _Translations$common$es._(_root); @override late final _Translations$common$es common = _Translations$common$es._(_root);
@override late final _Translations$home$es home = _Translations$home$es._(_root); @override late final _Translations$home$es home = _Translations$home$es._(_root);
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
@override late final _Translations$menu$es menu = _Translations$menu$es._(_root); @override late final _Translations$menu$es menu = _Translations$menu$es._(_root);
@override late final _Translations$settings$es settings = _Translations$settings$es._(_root); @override late final _Translations$settings$es settings = _Translations$settings$es._(_root);
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root); @override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
@ -91,6 +92,17 @@ class _Translations$home$es extends Translations$home$en {
@override String get yourInventory => 'Tu inventario'; @override String get yourInventory => '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 // Path: menu
class _Translations$menu$es extends Translations$menu$en { class _Translations$menu$es extends Translations$menu$en {
_Translations$menu$es._(TranslationsEs root) : this._root = root, super.internal(root); _Translations$menu$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -538,6 +550,8 @@ extension on TranslationsEs {
'common.comingSoon' => 'Pronto', 'common.comingSoon' => 'Pronto',
'home.openMarket' => 'Mercado abierto', 'home.openMarket' => 'Mercado abierto',
'home.yourInventory' => 'Tu inventario', 'home.yourInventory' => 'Tu inventario',
'photo.camera' => 'Hacer una foto',
'photo.gallery' => 'Elegir de la galería',
'menu.inventory' => 'Inventario', 'menu.inventory' => 'Inventario',
'menu.market' => 'Mercado', 'menu.market' => 'Mercado',
'menu.profile' => 'Tu perfil', 'menu.profile' => 'Tu perfil',

View file

@ -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<Uint8List?> pickPhoto(BuildContext context) async {
final t = context.t;
final source = await showModalBottomSheet<ImageSource>(
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;
}
}

View file

@ -1,46 +1,28 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import '../data/variety_repository.dart'; 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 'photo_pick.dart';
import 'quantity_picker.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<Uint8List?> Function();
Future<void> showQuickAddSheet( Future<void> showQuickAddSheet(
BuildContext context, { BuildContext context, {
required VarietyRepository repository, required VarietyRepository repository,
PhotoPicker? photoPicker,
}) { }) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => BlocProvider( builder: (_) => BlocProvider(
create: (_) => QuickAddCubit(repository), create: (_) => QuickAddCubit(repository),
child: QuickAddSheet(photoPicker: photoPicker ?? _cameraPhotoPicker), child: const QuickAddSheet(),
), ),
); );
} }
Future<Uint8List?> _cameraPhotoPicker() async {
final file = await ImagePicker().pickImage(
source: ImageSource.camera,
maxWidth: 1280,
imageQuality: 80,
);
return file?.readAsBytes();
}
class QuickAddSheet extends StatelessWidget { class QuickAddSheet extends StatelessWidget {
const QuickAddSheet({required this.photoPicker, super.key}); const QuickAddSheet({super.key});
final PhotoPicker photoPicker;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -111,7 +93,7 @@ class QuickAddSheet extends StatelessWidget {
label: Text(t.quickAdd.more), label: Text(t.quickAdd.more),
), ),
), ),
if (state.expanded) _MoreSection(photoPicker: photoPicker), if (state.expanded) const _MoreSection(),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
@ -136,9 +118,7 @@ class QuickAddSheet extends StatelessWidget {
} }
class _MoreSection extends StatelessWidget { class _MoreSection extends StatelessWidget {
const _MoreSection({required this.photoPicker}); const _MoreSection();
final PhotoPicker photoPicker;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -162,7 +142,7 @@ class _MoreSection extends StatelessWidget {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () async { onPressed: () async {
final bytes = await photoPicker(); final bytes = await pickPhoto(context);
if (bytes != null) cubit.photoPicked(bytes); if (bytes != null) cubit.photoPicked(bytes);
}, },
icon: const Icon(Icons.photo_camera_outlined), icon: const Icon(Icons.photo_camera_outlined),

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -10,6 +11,7 @@ import '../db/enums.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart'; import '../state/variety_detail_cubit.dart';
import 'harvest_date_picker.dart'; import 'harvest_date_picker.dart';
import 'photo_pick.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart'; import 'quantity_picker.dart';
import 'seed_glyph.dart'; import 'seed_glyph.dart';
@ -854,12 +856,7 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
} }
Future<void> _addPhoto(VarietyDetailCubit cubit) async { Future<void> _addPhoto(VarietyDetailCubit cubit) async {
final file = await ImagePicker().pickImage( final bytes = await pickPhoto(context);
source: ImageSource.gallery,
maxWidth: 1280,
imageQuality: 80,
);
final bytes = await file?.readAsBytes();
if (bytes != null) await cubit.addPhoto(bytes); if (bytes != null) await cubit.addPhoto(bytes);
} }
@ -1042,16 +1039,49 @@ class _PhotoViewer extends StatelessWidget {
body: PageView.builder( body: PageView.builder(
controller: PageController(initialPage: initialIndex), controller: PageController(initialPage: initialIndex),
itemCount: photos.length, itemCount: photos.length,
itemBuilder: (_, i) => InteractiveViewer( itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes),
minScale: 1,
maxScale: 5,
child: Center(child: Image.memory(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 { class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text); const _SectionTitle(this.text);