feat(remote-camera): add delete, share, download and redesign photo viewer
This commit is contained in:
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user