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:
parent
4b209d6752
commit
c789cd1027
8 changed files with 136 additions and 40 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
46
apps/app_seeds/lib/ui/photo_pick.dart
Normal file
46
apps/app_seeds/lib/ui/photo_pick.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue