feat(remote-camera): add delete, share, download and redesign photo viewer

This commit is contained in:
2026-04-26 05:12:18 +02:00
parent cb897a3243
commit c7fefe2a8b
10 changed files with 351 additions and 89 deletions

View File

@@ -4,4 +4,6 @@ abstract class PicturesRemoteDatasource {
Future<List<PictureEntity>> getPictures({required String deviceId});
Future<PictureEntity> takePicture({required String deviceId});
Future<void> deletePicture({required String photoId});
}

View File

@@ -27,4 +27,12 @@ class PicturesRemoteDatasourceImpl implements PicturesRemoteDatasource {
Future<PictureEntity> takePicture({required String deviceId}) async {
throw UnimplementedError('takePicture is handled via commands');
}
@override
Future<void> deletePicture({required String photoId}) async {
await safeCall(
() => _repository.delete<dynamic>('/photos/$photoId'),
'Error deleting photo',
);
}
}

View File

@@ -24,4 +24,9 @@ class PicturesRepositoryImpl implements PicturesRepository {
Future<PictureEntity> takePicture({required String deviceId}) {
return _remote.takePicture(deviceId: deviceId);
}
@override
Future<void> deletePicture({required String photoId}) {
return _remote.deletePicture(photoId: photoId);
}
}

View File

@@ -4,4 +4,6 @@ abstract class PicturesRepository {
Future<List<PictureEntity>> getPictures({required String deviceId});
Future<PictureEntity> takePicture({required String deviceId});
Future<void> deletePicture({required String photoId});
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:device_management/src/core/providers/pictures_repository_provider.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_pictures_provider.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -7,7 +8,7 @@ import 'package:sf_tracking/sf_tracking.dart';
part 'remote_camera_controller.g.dart';
const int _photoWaitSeconds = 5;
const int _photoWaitSeconds = 20;
class RemoteCameraState {
const RemoteCameraState({
@@ -33,7 +34,7 @@ class RemoteCameraState {
}
}
@riverpod
@Riverpod(keepAlive: true)
class RemoteCameraController extends _$RemoteCameraController {
Timer? _timer;
@@ -92,4 +93,12 @@ class RemoteCameraController extends _$RemoteCameraController {
await completer.future;
ref.invalidate(remotePicturesProvider(deviceIdentificator));
}
Future<void> deletePicture({
required String photoId,
required String deviceIdentificator,
}) async {
await ref.read(picturesRepositoryProvider).deletePicture(photoId: photoId);
ref.invalidate(remotePicturesProvider(deviceIdentificator));
}
}

View File

@@ -20,7 +20,7 @@ final class RemoteCameraControllerProvider
argument: null,
retry: null,
name: r'remoteCameraControllerProvider',
isAutoDispose: true,
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@@ -42,7 +42,7 @@ final class RemoteCameraControllerProvider
}
String _$remoteCameraControllerHash() =>
r'2f947b277991072a2d95072528931fcb13569af8';
r'ea28fee4d4e4a6793bbe0a3d5d054f5cacfbdd3b';
abstract class _$RemoteCameraController extends $Notifier<RemoteCameraState> {
RemoteCameraState build();

View File

@@ -92,7 +92,7 @@ class _GallerySection extends ConsumerWidget {
(int index) => TextButton(
onPressed: () {
ref.read(remotePictureIndexProvider.notifier).set(index);
showDialog<void>(
showLegacyDialog<void>(
context: context,
builder: (_) => const Dialog(child: ShowPictureDialog()),
);

View File

@@ -33,12 +33,10 @@ class RemoteConnectionScreen extends ConsumerWidget {
children: [
if (cameraEnabled) ...[
_SectionButton(
onPressed: () async {
if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
LegacyPageRoute(
builder: (_) => RemoteCameraScreen(
navigationContract: navigationContract,
),
@@ -54,7 +52,7 @@ class RemoteConnectionScreen extends ConsumerWidget {
onPressed: () async {
if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
showDialog(
showLegacyDialog(
context: context,
builder: (context) => Dialog(child: SpyCallDialog()),
);

View File

@@ -1,17 +1,83 @@
import 'package:device_management/src/features/remote_connection/domain/entities/picture_entity.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_camera_controller.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_picture_index_provider.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_pictures_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
import 'package:share_plus/share_plus.dart';
class ShowPictureDialog extends ConsumerWidget {
class ShowPictureDialog extends ConsumerStatefulWidget {
const ShowPictureDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ShowPictureDialog> createState() => _ShowPictureDialogState();
}
class _ShowPictureDialogState extends ConsumerState<ShowPictureDialog> {
late PageController _pageController;
@override
void initState() {
super.initState();
final initialIndex = ref.read(remotePictureIndexProvider);
_pageController = PageController(initialPage: initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _goTo(int index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
Future<void> _sharePhoto(Uint8List bytes) async {
try {
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/savefamily_photo_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await file.writeAsBytes(bytes);
await Share.shareXFiles([XFile(file.path)]);
} catch (_) {
if (!mounted) return;
await showErrorDialog(context, I18n.errorGeneric);
}
}
void _confirmDelete({
required String photoId,
required String deviceIdentificator,
}) {
showLegacyDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _DeletePhotoDialog(
photoId: photoId,
deviceIdentificator: deviceIdentificator,
onDeleted: () {
Navigator.of(dialogContext).pop();
if (mounted) Navigator.of(context).pop();
},
),
);
}
@override
Widget build(BuildContext context) {
final device = ref.watch(selectedDeviceProvider).value;
if (device == null) return const SizedBox.shrink();
@@ -20,41 +86,208 @@ class ShowPictureDialog extends ConsumerWidget {
final pictureIndex = ref.watch(remotePictureIndexProvider);
final pictures = picturesAsync.value ?? const [];
if (pictures.isEmpty || pictureIndex >= pictures.length) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
height: SizeUtils.getByScreen(small: 200, big: 190),
child: Center(
child: Text(
context.translate(I18n.noPhotosAvailable),
style: const TextStyle(color: Colors.grey),
),
if (pictures.isEmpty) {
return Center(
child: Text(
context.translate(I18n.noPhotosAvailable),
style: const TextStyle(color: Colors.grey),
),
);
}
final picture = pictures[pictureIndex];
final safeIndex = pictureIndex.clamp(0, pictures.length - 1);
final picture = pictures[safeIndex];
final primaryColor = context.sfColors.legacyPrimary;
final date = DateTime.fromMillisecondsSinceEpoch(picture.createdAt);
final dateStr =
'${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year} '
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return Container(
width: double.infinity,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(8)),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
height: SizeUtils.getByScreen(small: 350, big: 340),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _PictureSection(picture: picture)),
_MetadataSection(picture: picture),
_ControlsSection(
prev: () => ref
.read(remotePictureIndexProvider.notifier)
.prev(total: pictures.length),
next: () => ref
.read(remotePictureIndexProvider.notifier)
.next(total: pictures.length),
Stack(
children: [
Container(
width: double.infinity,
constraints: const BoxConstraints(
minHeight: 280,
maxHeight: 380,
),
color: Colors.black,
child: PageView.builder(
controller: _pageController,
itemCount: pictures.length,
onPageChanged: (index) {
ref.read(remotePictureIndexProvider.notifier).set(index);
},
itemBuilder: (_, index) {
final p = pictures[index];
if (p.fileBytes != null) {
return Image.memory(p.fileBytes!, fit: BoxFit.contain);
}
return const Center(
child: Icon(Icons.broken_image, size: 64, color: Colors.grey),
);
},
),
),
Positioned(
top: 8,
right: 8,
child: Material(
color: Colors.black38,
shape: const CircleBorder(),
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white, size: 22),
),
),
),
Positioned(
bottom: 12,
left: 0,
right: 0,
child: Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${safeIndex + 1} / ${pictures.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 12),
child: Row(
children: [
IconButton(
onPressed: () {
final prev = (safeIndex - 1).clamp(0, pictures.length - 1);
_goTo(prev);
},
icon: Icon(
Icons.arrow_back_ios_new_rounded,
color: primaryColor,
size: 20,
),
),
Expanded(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today_outlined,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
dateStr,
style: TextStyle(
fontSize: 13,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 10),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 24,
children: [
GestureDetector(
onTap: picture.fileBytes != null
? () => _sharePhoto(picture.fileBytes!)
: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.download_outlined,
size: 18,
color: primaryColor,
),
const SizedBox(width: 4),
Text(
context.translate(I18n.savePhoto),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: primaryColor,
),
),
],
),
),
GestureDetector(
onTap: () => _confirmDelete(
photoId: picture.id,
deviceIdentificator: device.identificator,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.delete_outline,
size: 18,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 4),
Text(
context.translate(I18n.deletePhoto),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
],
),
),
],
),
),
IconButton(
onPressed: () {
final next = (safeIndex + 1).clamp(0, pictures.length - 1);
_goTo(next);
},
icon: Icon(
Icons.arrow_forward_ios_rounded,
color: primaryColor,
size: 20,
),
),
],
),
),
],
),
@@ -62,67 +295,70 @@ class ShowPictureDialog extends ConsumerWidget {
}
}
class _PictureSection extends StatelessWidget {
final PictureEntity picture;
class _DeletePhotoDialog extends ConsumerStatefulWidget {
final String photoId;
final String deviceIdentificator;
final VoidCallback onDeleted;
const _PictureSection({required this.picture});
const _DeletePhotoDialog({
required this.photoId,
required this.deviceIdentificator,
required this.onDeleted,
});
@override
Widget build(BuildContext context) {
if (picture.fileBytes != null) {
return Center(
child: Image.memory(picture.fileBytes!, fit: BoxFit.contain),
);
ConsumerState<_DeletePhotoDialog> createState() => _DeletePhotoDialogState();
}
class _DeletePhotoDialogState extends ConsumerState<_DeletePhotoDialog> {
bool _isDeleting = false;
Future<void> _delete() async {
setState(() => _isDeleting = true);
try {
await ref.read(remoteCameraControllerProvider.notifier).deletePicture(
photoId: widget.photoId,
deviceIdentificator: widget.deviceIdentificator,
);
if (!mounted) return;
widget.onDeleted();
} catch (_) {
if (!mounted) return;
Navigator.of(context).pop();
await showErrorDialog(context, I18n.errorDeletePhoto);
}
return const Center(
child: Icon(Icons.broken_image, size: 64, color: Colors.grey),
);
}
}
class _MetadataSection extends StatelessWidget {
final PictureEntity picture;
const _MetadataSection({required this.picture});
@override
Widget build(BuildContext context) {
final date = DateTime.fromMillisecondsSinceEpoch(picture.createdAt);
final dateStr =
'${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year} '
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
dateStr,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
return AlertDialog(
icon: Icon(
Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.error,
size: 40,
),
);
}
}
class _ControlsSection extends StatelessWidget {
final VoidCallback prev;
final VoidCallback next;
const _ControlsSection({required this.prev, required this.next});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: prev,
icon: const Icon(Icons.arrow_back_ios_new_rounded),
title: Text(context.translate(I18n.deletePhoto)),
content: Text(context.translate(I18n.deletePhotoConfirm)),
actions: [
TextButton(
onPressed: _isDeleting ? null : () => Navigator.of(context).pop(),
child: Text(context.translate(I18n.cancel)),
),
IconButton(
onPressed: next,
icon: const Icon(Icons.arrow_forward_ios_rounded),
FilledButton(
onPressed: _isDeleting ? null : _delete,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: _isDeleting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(context.translate(I18n.delete)),
),
],
);

View File

@@ -68,6 +68,8 @@ dependencies:
intl: ^0.20.2
lottie: ^3.3.1
image_picker: ^1.2.1
share_plus: ^10.1.4
path_provider: ^2.1.5
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.