nc-photos/app/lib/widget/enhanced_photo_browser.dart
2023-10-03 22:28:35 +08:00

447 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/scan_local_dir.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/android/android_info.dart';
import 'package:nc_photos/mobile/android/permission_util.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/widget/empty_list_indicator.dart';
import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart';
import 'package:nc_photos/widget/local_file_viewer.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
import 'package:nc_photos/widget/selection_app_bar.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
import 'package:np_async/np_async.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
import 'package:np_common/object_util.dart';
import 'package:np_platform_permission/np_platform_permission.dart';
import 'package:np_platform_util/np_platform_util.dart';
part 'enhanced_photo_browser.g.dart';
class EnhancedPhotoBrowserArguments {
const EnhancedPhotoBrowserArguments(this.filename);
final String? filename;
}
class EnhancedPhotoBrowser extends StatefulWidget {
static const routeName = "/enhanced-photo-browser";
static Route buildRoute(EnhancedPhotoBrowserArguments args) =>
MaterialPageRoute(
builder: (context) => EnhancedPhotoBrowser.fromArgs(args),
);
const EnhancedPhotoBrowser({
Key? key,
required this.filename,
}) : super(key: key);
EnhancedPhotoBrowser.fromArgs(EnhancedPhotoBrowserArguments args, {Key? key})
: this(
key: key,
filename: args.filename,
);
@override
createState() => _EnhancedPhotoBrowserState();
final String? filename;
}
@npLog
class _EnhancedPhotoBrowserState extends State<EnhancedPhotoBrowser>
with SelectableItemStreamListMixin<EnhancedPhotoBrowser> {
@override
initState() {
super.initState();
_thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0);
_ensurePermission().then((value) {
if (value) {
_initBloc();
} else {
if (mounted) {
setState(() {
_isNoPermission = true;
});
}
}
});
}
@override
build(BuildContext context) {
return Scaffold(
body: BlocListener<ScanLocalDirBloc, ScanLocalDirBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ScanLocalDirBloc, ScanLocalDirBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
),
);
}
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListLocalFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
LocalFileViewer.routeName,
arguments: LocalFileViewerArguments(_backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() {
if (_bloc.state is ScanLocalDirBlocInit) {
_log.info("[_initBloc] Initialize bloc");
_reqQuery();
} else {
// process the current state
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_onStateChange(context, _bloc.state);
});
}
_reqQuery();
});
}
}
Widget _buildContent(BuildContext context, ScanLocalDirBlocState state) {
if (_isNoPermission) {
return Column(
children: [
AppBar(
title: Text(L10n.global().collectionEditedPhotosLabel),
elevation: 0,
),
Expanded(
child: EmptyListIndicator(
icon: Icons.folder_off_outlined,
text: L10n.global().errorNoStoragePermission,
),
),
],
);
} else if (state is ScanLocalDirBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty) {
return Column(
children: [
AppBar(
title: Text(L10n.global().collectionEditedPhotosLabel),
elevation: 0,
),
Expanded(
child: EmptyListIndicator(
icon: Icons.folder_outlined,
text: L10n.global().listEmptyText,
),
),
],
);
} else {
return Stack(
children: [
buildItemStreamListOuter(
context,
child: CustomScrollView(
slivers: [
_buildAppBar(context),
buildItemStreamList(
maxCrossAxisExtent: _thumbSize.toDouble(),
),
],
),
),
if (state is ScanLocalDirBlocLoading || _buildItemQueue.isProcessing)
const Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(),
),
],
);
}
}
Widget _buildAppBar(BuildContext context) {
if (isSelectionMode) {
return _buildSelectionAppBar(context);
} else {
return _buildNormalAppBar(context);
}
}
Widget _buildNormalAppBar(BuildContext context) => SliverAppBar(
title: Text(L10n.global().collectionEditedPhotosLabel),
);
Widget _buildSelectionAppBar(BuildContext context) {
return SelectionAppBar(
count: selectedListItems.length,
onClosePressed: () {
setState(() {
clearSelectedItems();
});
},
actions: [
IconButton(
icon: const Icon(Icons.share),
tooltip: L10n.global().shareTooltip,
onPressed: () {
_onSelectionSharePressed(context);
},
),
PopupMenuButton<_SelectionMenuOption>(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
itemBuilder: (context) => [
PopupMenuItem(
value: _SelectionMenuOption.delete,
child: Text(L10n.global().deletePermanentlyTooltip),
),
],
onSelected: (option) => _onSelectionMenuSelected(context, option),
),
],
);
}
void _onStateChange(BuildContext context, ScanLocalDirBlocState state) {
if (state is ScanLocalDirBlocInit) {
itemStreamListItems = [];
} else if (state is ScanLocalDirBlocLoading) {
_transformItems(state.files);
} else if (state is ScanLocalDirBlocSuccess) {
_transformItems(state.files, isSuccess: true);
} else if (state is ScanLocalDirBlocFailure) {
_transformItems(state.files);
SnackBarManager().showSnackBar(SnackBar(
content: Text(state.exception is PermissionException
? L10n.global().errorNoStoragePermission
: exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
}
}
Future<void> _onSelectionSharePressed(BuildContext context) async {
final c = KiwiContainer().resolve<DiContainer>();
final selected = selectedListItems
.whereType<PhotoListLocalFileItem>()
.map((e) => e.file)
.toList();
await ShareHandler(
c,
context: context,
clearSelection: () {
setState(() {
clearSelectedItems();
});
},
).shareLocalFiles(selected);
}
void _onSelectionMenuSelected(
BuildContext context, _SelectionMenuOption option) {
switch (option) {
case _SelectionMenuOption.delete:
_onSelectionDeletePressed(context);
break;
default:
_log.shout("[_onSelectionMenuSelected] Unknown option: $option");
break;
}
}
Future<void> _onSelectionDeletePressed(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(L10n.global().deletePermanentlyConfirmationDialogTitle),
content: Text(
L10n.global().deletePermanentlyLocalConfirmationDialogContent,
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(L10n.global().confirmButtonLabel),
),
],
),
);
if (result != true) {
return;
}
final selectedFiles = selectedListItems
.whereType<PhotoListLocalFileItem>()
.map((e) => e.file)
.toList();
setState(() {
clearSelectedItems();
});
await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles);
}
void _transformItems(
List<LocalFile> files, {
bool isSuccess = false,
}) {
_buildItemQueue.addJob(
_BuilderArguments(files),
_buildPhotoListItem,
(result) {
if (mounted) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
});
if (isSuccess && _isFirstRun) {
_isFirstRun = false;
if (widget.filename != null) {
_openInitialImage(widget.filename!);
}
}
}
},
);
}
void _openInitialImage(String filename) {
final index = _backingFiles.indexWhere((f) => f.filename == filename);
if (index == -1) {
_log.severe("[openInitialImage] Filename not found: $filename");
return;
}
Navigator.pushNamed(context, LocalFileViewer.routeName,
arguments: LocalFileViewerArguments(_backingFiles, index));
}
Future<bool> _ensurePermission() async {
if (getRawPlatform() == NpPlatform.android) {
if (AndroidInfo().sdkInt >= AndroidVersion.R) {
if (!await Permission.hasReadExternalStorage()) {
final results = await requestReadExternalStorageForResult();
return results[Permission.READ_EXTERNAL_STORAGE] ==
PermissionRequestResult.granted ||
results[Permission.READ_MEDIA_IMAGES] ==
PermissionRequestResult.granted;
}
} else {
if (!await Permission.hasWriteExternalStorage()) {
final results = await requestPermissionsForResult([
Permission.WRITE_EXTERNAL_STORAGE,
]);
return results[Permission.WRITE_EXTERNAL_STORAGE] ==
PermissionRequestResult.granted;
}
}
}
return true;
}
void _reqQuery() {
_bloc.add(const ScanLocalDirBlocQuery([
"Download/Photos (for Nextcloud)/Enhanced Photos",
"Download/Photos (for Nextcloud)/Edited Photos",
]));
}
final _bloc = ScanLocalDirBloc();
var _backingFiles = <LocalFile>[];
final _buildItemQueue = ComputeQueue<_BuilderArguments, _BuilderResult>();
var _isFirstRun = true;
var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
var _isNoPermission = false;
}
enum _SelectionMenuOption {
delete,
}
class _BuilderResult {
const _BuilderResult(this.backingFiles, this.listItems);
final List<LocalFile> backingFiles;
final List<SelectableItem> listItems;
}
class _BuilderArguments {
const _BuilderArguments(this.files);
final List<LocalFile> files;
}
@npLog
class _Builder {
_BuilderResult call(List<LocalFile> files) {
final s = Stopwatch()..start();
try {
return _fromSortedItems(_sortItems(files));
} finally {
_log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms");
}
}
List<LocalFile> _sortItems(List<LocalFile> files) {
// we use last modified here to keep newly enhanced photo at the top
return files
.stableSorted((a, b) => b.lastModified.compareTo(a.lastModified));
}
_BuilderResult _fromSortedItems(List<LocalFile> files) {
final backingFiles = <LocalFile>[];
final listItems = <SelectableItem>[];
for (int i = 0; i < files.length; ++i) {
final f = files[i];
final item = _buildListItem(i, f);
if (item != null) {
backingFiles.add(f);
listItems.add(item);
}
}
return _BuilderResult(backingFiles, listItems);
}
SelectableItem? _buildListItem(int i, LocalFile file) {
if (file_util.isSupportedImageMime(file.mime ?? "")) {
return PhotoListLocalImageItem(
fileIndex: i,
file: file,
);
} else {
_log.shout("[_buildListItem] Unsupported file format: ${file.mime}");
return null;
}
}
}
_BuilderResult _buildPhotoListItem(_BuilderArguments arg) {
app_init.initLog();
return _Builder()(arg.files);
}