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

@ -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_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<Uint8List?> Function();
Future<void> showQuickAddSheet(
BuildContext context, {
required VarietyRepository repository,
PhotoPicker? photoPicker,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => BlocProvider(
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 {
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),

View file

@ -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<void> _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);