mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 05:22:13 +00:00
545 lines
19 KiB
Dart
545 lines
19 KiB
Dart
import 'package:drift/drift.dart' show Value;
|
|
import 'package:flutter/material.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/memories/memories.service.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
|
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
|
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
|
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
|
|
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
|
|
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
|
|
import 'package:twonly/src/visual/views/memories/components/selection_toolbar.comp.dart';
|
|
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
|
|
|
|
class MemoriesView extends StatefulWidget {
|
|
const MemoriesView({super.key});
|
|
|
|
@override
|
|
State<MemoriesView> createState() => MemoriesViewState();
|
|
}
|
|
|
|
class MemoriesViewState extends State<MemoriesView> {
|
|
late final MemoriesService _service;
|
|
final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null);
|
|
final ScrollController _scrollController = ScrollController();
|
|
bool _isViewingFlashback = false;
|
|
|
|
final Set<String> _selectedMediaIds = {};
|
|
bool _filterFavoritesOnly = false;
|
|
bool get _selectionMode => _selectedMediaIds.isNotEmpty;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_service = MemoriesService();
|
|
_activeMediaIdNotifier.addListener(_onActiveMediaChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_activeMediaIdNotifier.removeListener(_onActiveMediaChanged);
|
|
_scrollController.dispose();
|
|
_service.dispose();
|
|
_activeMediaIdNotifier.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onActiveMediaChanged() {
|
|
if (_isViewingFlashback) return;
|
|
final mediaId = _activeMediaIdNotifier.value;
|
|
if (mediaId == null) return;
|
|
final state = _service.currentState;
|
|
if (state.isEmpty) return;
|
|
|
|
final index = state.galleryItems.indexWhere(
|
|
(item) => item.mediaService.mediaFile.mediaId == mediaId,
|
|
);
|
|
if (index == -1) return;
|
|
|
|
double offset = 56;
|
|
if (state.galleryItemsLastYears.isNotEmpty) {
|
|
offset += 220;
|
|
}
|
|
|
|
final screenWidth = MediaQuery.sizeOf(context).width;
|
|
final itemWidth = (screenWidth - 8) / 4;
|
|
final itemHeight = itemWidth * (16 / 9);
|
|
final rowHeight = itemHeight + 2;
|
|
|
|
for (final month in state.months) {
|
|
final indices = state.orderedByMonth[month]!;
|
|
offset += 44;
|
|
|
|
if (indices.contains(index)) {
|
|
final localIdx = indices.indexOf(index);
|
|
final row = localIdx ~/ 4;
|
|
offset += row * rowHeight;
|
|
break;
|
|
} else {
|
|
final totalRows = (indices.length + 3) ~/ 4;
|
|
offset += totalRows * rowHeight;
|
|
}
|
|
}
|
|
|
|
if (_scrollController.hasClients) {
|
|
final targetOffset = (offset - 100).clamp(
|
|
0.0,
|
|
_scrollController.position.maxScrollExtent,
|
|
);
|
|
_scrollController.jumpTo(targetOffset);
|
|
}
|
|
}
|
|
|
|
Future<void> _openViewer(
|
|
List<MemoryItem> items,
|
|
int index, {
|
|
bool isFlashback = false,
|
|
}) async {
|
|
if (isFlashback) {
|
|
_isViewingFlashback = true;
|
|
}
|
|
_activeMediaIdNotifier.value = items[index].mediaService.mediaFile.mediaId;
|
|
|
|
await Navigator.push(
|
|
context,
|
|
PageRouteBuilder(
|
|
opaque: false,
|
|
transitionDuration: const Duration(milliseconds: 350),
|
|
reverseTransitionDuration: const Duration(milliseconds: 350),
|
|
pageBuilder: (context, animation, secondaryAnimation) {
|
|
return SynchronizedImageViewerScreen(
|
|
galleryItems: items,
|
|
initialIndex: index,
|
|
activeMediaIdNotifier: _activeMediaIdNotifier,
|
|
);
|
|
},
|
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: child,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
if (isFlashback) {
|
|
_isViewingFlashback = false;
|
|
}
|
|
}
|
|
|
|
void _toggleSelection(String mediaId) {
|
|
setState(() {
|
|
if (_selectedMediaIds.contains(mediaId)) {
|
|
_selectedMediaIds.remove(mediaId);
|
|
} else {
|
|
_selectedMediaIds.add(mediaId);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onLongPressItem(String mediaId) {
|
|
setState(() {
|
|
_selectedMediaIds.add(mediaId);
|
|
});
|
|
}
|
|
|
|
void _onTapItem(String mediaId, int globalIndex) {
|
|
if (_selectionMode) {
|
|
_toggleSelection(mediaId);
|
|
} else {
|
|
final state = _service.currentState;
|
|
var targetItems = state.galleryItems;
|
|
var targetIndex = globalIndex;
|
|
|
|
if (_filterFavoritesOnly) {
|
|
targetItems = state.galleryItems
|
|
.where((e) => e.mediaService.mediaFile.isFavorite)
|
|
.toList();
|
|
targetIndex = targetItems.indexWhere(
|
|
(e) => e.mediaService.mediaFile.mediaId == mediaId,
|
|
);
|
|
if (targetIndex == -1) targetIndex = 0;
|
|
}
|
|
|
|
_openViewer(targetItems, targetIndex);
|
|
}
|
|
}
|
|
|
|
void _selectAll() {
|
|
setState(() {
|
|
final items = _service.currentState.galleryItems;
|
|
final targetIds = <String>{};
|
|
|
|
for (final item in items) {
|
|
if (_filterFavoritesOnly) {
|
|
if (item.mediaService.mediaFile.isFavorite) {
|
|
targetIds.add(item.mediaService.mediaFile.mediaId);
|
|
}
|
|
} else {
|
|
targetIds.add(item.mediaService.mediaFile.mediaId);
|
|
}
|
|
}
|
|
|
|
final areAllSelected = targetIds.every(_selectedMediaIds.contains);
|
|
|
|
if (areAllSelected) {
|
|
_selectedMediaIds.removeAll(targetIds);
|
|
} else {
|
|
_selectedMediaIds.addAll(targetIds);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _batchDelete() async {
|
|
final count = _selectedMediaIds.length;
|
|
final confirmed = await showAlertDialog(
|
|
context,
|
|
context.lang.deleteImageTitle,
|
|
context.lang.deleteImageBody,
|
|
);
|
|
|
|
if (!confirmed) return;
|
|
|
|
final items = _service.currentState.galleryItems;
|
|
for (final mediaId in _selectedMediaIds) {
|
|
final item = items
|
|
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
|
.firstOrNull;
|
|
if (item != null) {
|
|
item.mediaService.fullMediaRemoval();
|
|
}
|
|
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
|
}
|
|
|
|
setState(_selectedMediaIds.clear);
|
|
|
|
if (!mounted) return;
|
|
showSnackbar(
|
|
context,
|
|
'Deleted $count items successfully',
|
|
level: SnackbarLevel.success,
|
|
);
|
|
}
|
|
|
|
Future<void> _batchExport() async {
|
|
final items = _service.currentState.galleryItems;
|
|
|
|
try {
|
|
for (final mediaId in _selectedMediaIds) {
|
|
final item = items
|
|
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
|
.firstOrNull;
|
|
if (item != null) {
|
|
final media = item.mediaService;
|
|
if (media.mediaFile.type == MediaType.video) {
|
|
await saveVideoToGallery(media.storedPath.path);
|
|
} else if (media.mediaFile.type == MediaType.image ||
|
|
media.mediaFile.type == MediaType.gif) {
|
|
final imageBytes = await media.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());
|
|
}
|
|
}
|
|
|
|
Future<void> _batchFavorite() async {
|
|
final items = _service.currentState.galleryItems;
|
|
var favCount = 0;
|
|
for (final item in items) {
|
|
if (_selectedMediaIds.contains(item.mediaService.mediaFile.mediaId)) {
|
|
if (item.mediaService.mediaFile.isFavorite) {
|
|
favCount++;
|
|
}
|
|
}
|
|
}
|
|
final areAllFav =
|
|
_selectedMediaIds.isNotEmpty && favCount == _selectedMediaIds.length;
|
|
final targetFav = !areAllFav;
|
|
|
|
for (final mediaId in _selectedMediaIds) {
|
|
await twonlyDB.mediaFilesDao.updateMedia(
|
|
mediaId,
|
|
MediaFilesCompanion(isFavorite: Value(targetFav)),
|
|
);
|
|
}
|
|
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
StreamBuilder<MemoriesState>(
|
|
initialData: _service.currentState,
|
|
stream: _service.watchState,
|
|
builder: (context, snapshot) {
|
|
final state = snapshot.data ?? _service.currentState;
|
|
|
|
if (state.isLoading) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
ThreeRotatingDots(
|
|
size: 40,
|
|
color: context.color.primary,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
context.lang.migrationOfMemories(state.filesToMigrate),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.photo_library_outlined,
|
|
size: 64,
|
|
color: Colors.grey.shade400,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
context.lang.memoriesEmpty,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.grey.shade600,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
var months = state.months;
|
|
var orderedByMonth = state.orderedByMonth;
|
|
final lastYears = state.galleryItemsLastYears;
|
|
|
|
if (_filterFavoritesOnly) {
|
|
final filteredOrdered = <String, List<int>>{};
|
|
final filteredMonths = <String>[];
|
|
|
|
for (final m in months) {
|
|
final indices = orderedByMonth[m] ?? [];
|
|
final favIndices = indices.where((idx) {
|
|
return state
|
|
.galleryItems[idx]
|
|
.mediaService
|
|
.mediaFile
|
|
.isFavorite;
|
|
}).toList();
|
|
|
|
if (favIndices.isNotEmpty) {
|
|
filteredOrdered[m] = favIndices;
|
|
filteredMonths.add(m);
|
|
}
|
|
}
|
|
|
|
months = filteredMonths;
|
|
orderedByMonth = filteredOrdered;
|
|
}
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return DraggableScrollbar(
|
|
controller: _scrollController,
|
|
labelBuilder: (offset) {
|
|
final state = _service.currentState;
|
|
if (state.isEmpty) return null;
|
|
|
|
// Simple heuristic to find month by offset
|
|
double currentOffset = 56;
|
|
if (state.galleryItemsLastYears.isNotEmpty) {
|
|
currentOffset += 220;
|
|
}
|
|
|
|
final screenWidth = MediaQuery.sizeOf(context).width;
|
|
final itemWidth = (screenWidth - 8) / 4;
|
|
final itemHeight = itemWidth * (16 / 9);
|
|
final rowHeight = itemHeight + 2;
|
|
|
|
for (final month in state.months) {
|
|
final indices = state.orderedByMonth[month]!;
|
|
final totalRows = (indices.length + 3) ~/ 4;
|
|
final monthHeight = 44 + (totalRows * rowHeight);
|
|
|
|
if (offset < currentOffset + monthHeight) {
|
|
return month;
|
|
}
|
|
currentOffset += monthHeight;
|
|
}
|
|
return state.months.last;
|
|
},
|
|
child: CustomScrollView(
|
|
controller: _scrollController,
|
|
physics: const BouncingScrollPhysics(),
|
|
slivers: [
|
|
SliverAppBar(
|
|
title: const Text(
|
|
'Memories',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
floating: true,
|
|
snap: true,
|
|
elevation: 0,
|
|
backgroundColor: context.color.surface,
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
_filterFavoritesOnly
|
|
? Icons.favorite
|
|
: Icons.favorite_border,
|
|
color: _filterFavoritesOnly
|
|
? Colors.redAccent
|
|
: null,
|
|
),
|
|
onPressed: () {
|
|
setState(() {
|
|
_filterFavoritesOnly = !_filterFavoritesOnly;
|
|
});
|
|
},
|
|
tooltip: _filterFavoritesOnly
|
|
? 'Show all'
|
|
: 'Show favorites only',
|
|
),
|
|
],
|
|
),
|
|
MemoriesFlashbackBannerComp(
|
|
lastYears: lastYears,
|
|
onOpenFlashback: (items, idx) =>
|
|
_openViewer(items, idx, isFlashback: true),
|
|
),
|
|
for (final month in months) ...[
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
|
|
sliver: SliverToBoxAdapter(
|
|
child: Text(
|
|
month,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SliverGrid(
|
|
gridDelegate:
|
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4,
|
|
mainAxisSpacing: 2,
|
|
crossAxisSpacing: 2,
|
|
childAspectRatio: 9 / 16,
|
|
),
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, idx) {
|
|
final globalIndex = orderedByMonth[month]![idx];
|
|
final item = state.galleryItems[globalIndex];
|
|
final mediaId =
|
|
item.mediaService.mediaFile.mediaId;
|
|
final isSelected = _selectedMediaIds.contains(
|
|
mediaId,
|
|
);
|
|
|
|
return MemoriesThumbnailComp(
|
|
galleryItem: item,
|
|
index: globalIndex,
|
|
selectionMode: _selectionMode,
|
|
isSelected: isSelected,
|
|
activeMediaIdNotifier: _activeMediaIdNotifier,
|
|
onLongPress: () => _onLongPressItem(mediaId),
|
|
onTap: () => _onTapItem(mediaId, globalIndex),
|
|
);
|
|
},
|
|
childCount: orderedByMonth[month]!.length,
|
|
),
|
|
),
|
|
],
|
|
const SliverPadding(
|
|
padding: EdgeInsets.only(bottom: 32),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
|
|
if (_selectionMode)
|
|
Builder(
|
|
builder: (context) {
|
|
final items = _service.currentState.galleryItems;
|
|
var visibleCount = 0;
|
|
var favCount = 0;
|
|
|
|
for (final item in items) {
|
|
final isFav = item.mediaService.mediaFile.isFavorite;
|
|
if (!_filterFavoritesOnly || isFav) {
|
|
visibleCount++;
|
|
}
|
|
if (_selectedMediaIds.contains(
|
|
item.mediaService.mediaFile.mediaId,
|
|
)) {
|
|
if (isFav) {
|
|
favCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
final areAllSelected =
|
|
visibleCount > 0 &&
|
|
_selectedMediaIds.length >= visibleCount;
|
|
final areAllFav =
|
|
_selectedMediaIds.isNotEmpty &&
|
|
favCount == _selectedMediaIds.length;
|
|
|
|
return MemoriesSelectionToolbarComp(
|
|
selectedCount: _selectedMediaIds.length,
|
|
areAllSelected: areAllSelected,
|
|
areAllFav: areAllFav,
|
|
onSelectAll: _selectAll,
|
|
onExport: _batchExport,
|
|
onFavorite: _batchFavorite,
|
|
onDelete: _batchDelete,
|
|
onClear: () => setState(_selectedMediaIds.clear),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|