mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-03 02:32:12 +00:00
383 lines
13 KiB
Dart
383 lines
13 KiB
Dart
import 'dart:math';
|
|
import 'package:drift/drift.dart' show Value;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:photo_view/photo_view.dart';
|
|
import 'package:twonly/locator.dart';
|
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
|
import 'package:twonly/src/database/twonly.db.dart';
|
|
import 'package:twonly/src/model/memory_item.model.dart';
|
|
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
|
|
import 'package:twonly/src/utils/log.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
|
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
|
|
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
|
|
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart';
|
|
|
|
class SynchronizedImageViewerScreen extends StatefulWidget {
|
|
const SynchronizedImageViewerScreen({
|
|
required this.galleryItems,
|
|
required this.initialIndex,
|
|
required this.activeMediaIdNotifier,
|
|
super.key,
|
|
});
|
|
|
|
final List<MemoryItem> galleryItems;
|
|
final int initialIndex;
|
|
final ValueNotifier<String?> activeMediaIdNotifier;
|
|
|
|
@override
|
|
State<SynchronizedImageViewerScreen> createState() =>
|
|
_SynchronizedImageViewerScreenState();
|
|
}
|
|
|
|
class _SynchronizedImageViewerScreenState
|
|
extends State<SynchronizedImageViewerScreen> {
|
|
late PageController _verticalPager;
|
|
late PageController _horizontalPager;
|
|
late ValueNotifier<String> _currentlyViewedMediaIdNotifier;
|
|
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
|
|
|
|
final Set<String> _favoritedMediaIds = {};
|
|
bool _isSaving = false;
|
|
final Set<String> _storedMediaIds = {};
|
|
|
|
late int _currentIndex;
|
|
bool _isZoomed = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
|
|
_currentIndex = widget.initialIndex;
|
|
final initialId =
|
|
widget.galleryItems[widget.initialIndex].mediaService.mediaFile.mediaId;
|
|
_currentlyViewedMediaIdNotifier = ValueNotifier(initialId);
|
|
|
|
_horizontalPager = PageController(initialPage: widget.initialIndex);
|
|
_verticalPager = PageController(initialPage: 1);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
|
|
});
|
|
|
|
for (final item in widget.galleryItems) {
|
|
if (item.mediaService.mediaFile.isFavorite) {
|
|
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
|
}
|
|
if (item.mediaService.storedPath.existsSync()) {
|
|
_storedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _storeMediaFile() async {
|
|
final item = widget.galleryItems[_currentIndex];
|
|
final mediaId = item.mediaService.mediaFile.mediaId;
|
|
setState(() => _isSaving = true);
|
|
try {
|
|
await item.mediaService.storeMediaFile();
|
|
if (mounted) {
|
|
setState(() {
|
|
_storedMediaIds.add(mediaId);
|
|
});
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _isSaving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleFavorite(String mediaId) async {
|
|
final wasFavorite = _favoritedMediaIds.contains(mediaId);
|
|
final isFavoriteNow = !wasFavorite;
|
|
|
|
setState(() {
|
|
if (isFavoriteNow) {
|
|
_favoritedMediaIds.add(mediaId);
|
|
} else {
|
|
_favoritedMediaIds.remove(mediaId);
|
|
}
|
|
});
|
|
|
|
await twonlyDB.mediaFilesDao.updateMedia(
|
|
mediaId,
|
|
MediaFilesCompanion(isFavorite: Value(isFavoriteNow)),
|
|
);
|
|
}
|
|
|
|
void _restoreSystemUI() {
|
|
SystemChrome.setEnabledSystemUIMode(
|
|
SystemUiMode.manual,
|
|
overlays: SystemUiOverlay.values,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_restoreSystemUI();
|
|
_verticalPager
|
|
..removeListener(_onVerticalScrollUpdated)
|
|
..dispose();
|
|
_horizontalPager.dispose();
|
|
_currentlyViewedMediaIdNotifier.dispose();
|
|
_backdropOpacityNotifier.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onVerticalScrollUpdated() {
|
|
if (!_verticalPager.hasClients) return;
|
|
final page = _verticalPager.page ?? 1.0;
|
|
|
|
// Map vertical dragging proximity directly to square-root backdrop opacities
|
|
final linearFraction = min(1, max(0, page)).toDouble();
|
|
_backdropOpacityNotifier.value = linearFraction * linearFraction;
|
|
}
|
|
|
|
void _onPageSnapped(int index) {
|
|
if (index == 0) {
|
|
_triggerSynchronizedPop();
|
|
}
|
|
}
|
|
|
|
void _triggerSynchronizedPop() {
|
|
_restoreSystemUI();
|
|
final targetId = _currentlyViewedMediaIdNotifier.value;
|
|
|
|
if (widget.activeMediaIdNotifier.value != targetId) {
|
|
widget.activeMediaIdNotifier.value = targetId;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) Navigator.maybeOf(context)?.pop(true);
|
|
});
|
|
} else {
|
|
Navigator.maybeOf(context)?.pop(true);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteFile() async {
|
|
final confirmed = await showAlertDialog(
|
|
context,
|
|
context.lang.deleteImageTitle,
|
|
context.lang.deleteImageBody,
|
|
);
|
|
|
|
if (!confirmed) return;
|
|
|
|
widget.galleryItems[_currentIndex].mediaService.fullMediaRemoval();
|
|
await twonlyDB.mediaFilesDao.deleteMediaFile(
|
|
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId,
|
|
);
|
|
|
|
widget.galleryItems.removeAt(_currentIndex);
|
|
|
|
if (widget.galleryItems.isEmpty) {
|
|
if (mounted) Navigator.pop(context, true);
|
|
return;
|
|
}
|
|
|
|
if (_currentIndex >= widget.galleryItems.length) {
|
|
_currentIndex = widget.galleryItems.length - 1;
|
|
}
|
|
|
|
final newId =
|
|
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
|
|
_currentlyViewedMediaIdNotifier.value = newId;
|
|
widget.activeMediaIdNotifier.value = newId;
|
|
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _exportFile() async {
|
|
final item = widget.galleryItems[_currentIndex].mediaService;
|
|
|
|
try {
|
|
if (item.mediaFile.type == MediaType.video) {
|
|
await saveVideoToGallery(item.storedPath.path);
|
|
} else if (item.mediaFile.type == MediaType.image ||
|
|
item.mediaFile.type == MediaType.gif) {
|
|
final imageBytes = await item.storedPath.readAsBytes();
|
|
await saveImageToGallery(imageBytes);
|
|
}
|
|
if (!mounted) return;
|
|
showSnackbar(
|
|
context,
|
|
context.lang.galleryExportSuccess,
|
|
level: SnackbarLevel.success,
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showSnackbar(
|
|
context,
|
|
e.toString(),
|
|
level: SnackbarLevel.success,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _shareMediaFile() async {
|
|
final orgMediaService = widget.galleryItems[_currentIndex].mediaService;
|
|
|
|
final newMediaService = await initializeMediaUpload(
|
|
orgMediaService.mediaFile.type,
|
|
userService.currentUser.defaultShowTime,
|
|
);
|
|
if (newMediaService == null) {
|
|
Log.error('Could not create new mediaFile');
|
|
return;
|
|
}
|
|
|
|
if (orgMediaService.storedPath.existsSync()) {
|
|
orgMediaService.storedPath.copySync(newMediaService.originalPath.path);
|
|
} else if (orgMediaService.tempPath.existsSync()) {
|
|
orgMediaService.tempPath.copySync(newMediaService.originalPath.path);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
await context.navPush(
|
|
ShareImageEditorView(
|
|
mediaFileService: newMediaService,
|
|
sharedFromGallery: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.galleryItems.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final orgMediaService = widget.galleryItems[_currentIndex].mediaService;
|
|
final currentMediaId = orgMediaService.mediaFile.mediaId;
|
|
|
|
return PopScope<Object?>(
|
|
onPopInvokedWithResult: (didPop, result) {
|
|
_restoreSystemUI();
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: ValueListenableBuilder<double>(
|
|
valueListenable: _backdropOpacityNotifier,
|
|
builder: (context, opacity, child) {
|
|
return ColoredBox(
|
|
color: Colors.black.withValues(alpha: opacity),
|
|
child: child,
|
|
);
|
|
},
|
|
child: PageView(
|
|
controller: _verticalPager,
|
|
scrollDirection: Axis.vertical,
|
|
physics: _isZoomed
|
|
? const NeverScrollableScrollPhysics()
|
|
: const BouncingScrollPhysics(
|
|
parent: AlwaysScrollableScrollPhysics(),
|
|
),
|
|
onPageChanged: _onPageSnapped,
|
|
children: [
|
|
//Fully transparent dismissal trigger anchor
|
|
const SizedBox.expand(),
|
|
|
|
Stack(
|
|
children: [
|
|
PageView.builder(
|
|
controller: _horizontalPager,
|
|
physics: _isZoomed
|
|
? const NeverScrollableScrollPhysics()
|
|
: const BouncingScrollPhysics(),
|
|
itemCount: widget.galleryItems.length,
|
|
onPageChanged: (idx) {
|
|
setState(() {
|
|
_currentIndex = idx;
|
|
});
|
|
final newMediaId = widget
|
|
.galleryItems[idx]
|
|
.mediaService
|
|
.mediaFile
|
|
.mediaId;
|
|
_currentlyViewedMediaIdNotifier.value = newMediaId;
|
|
widget.activeMediaIdNotifier.value = newMediaId;
|
|
},
|
|
itemBuilder: (context, index) {
|
|
final item = widget.galleryItems[index];
|
|
final itemMediaId = item.mediaService.mediaFile.mediaId;
|
|
|
|
var filePath = item.mediaService.storedPath;
|
|
if (!filePath.existsSync()) {
|
|
filePath = item.mediaService.tempPath;
|
|
}
|
|
|
|
final isVideo =
|
|
item.mediaService.mediaFile.type == MediaType.video;
|
|
|
|
return Center(
|
|
child: ValueListenableBuilder<String>(
|
|
valueListenable: _currentlyViewedMediaIdNotifier,
|
|
builder: (context, activeMediaId, childWidget) {
|
|
// Dynamically resolve Hero tags to prevent layout tree duplicate assertions
|
|
final isActiveTarget = activeMediaId == itemMediaId;
|
|
|
|
if (isActiveTarget) {
|
|
return Hero(
|
|
tag: itemMediaId,
|
|
transitionOnUserGestures: true,
|
|
child: childWidget!,
|
|
);
|
|
}
|
|
return childWidget!;
|
|
},
|
|
child: !filePath.existsSync()
|
|
? const Center(
|
|
child: Icon(
|
|
Icons.broken_image_outlined,
|
|
color: Colors.white38,
|
|
size: 64,
|
|
),
|
|
)
|
|
: isVideo
|
|
? VideoPlayerFileHelper(videoPath: filePath)
|
|
: PhotoView(
|
|
imageProvider: FileImage(filePath),
|
|
initialScale:
|
|
PhotoViewComputedScale.contained,
|
|
minScale: PhotoViewComputedScale.contained,
|
|
maxScale:
|
|
PhotoViewComputedScale.covered * 4.1,
|
|
backgroundDecoration: const BoxDecoration(
|
|
color: Colors.transparent,
|
|
),
|
|
scaleStateChangedCallback: (state) {
|
|
final zoomed =
|
|
state != PhotoViewScaleState.initial;
|
|
if (_isZoomed != zoomed) {
|
|
setState(() {
|
|
_isZoomed = zoomed;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
SynchronizedViewerActionsToolbarComp(
|
|
isFavorite: _favoritedMediaIds.contains(currentMediaId),
|
|
onShare: _shareMediaFile,
|
|
onExport: _exportFile,
|
|
onToggleFavorite: () => _toggleFavorite(currentMediaId),
|
|
onDelete: _deleteFile,
|
|
showStoreButton: !_storedMediaIds.contains(currentMediaId),
|
|
onStore: _storeMediaFile,
|
|
isImageSaving: _isSaving,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|