nc-photos/app/lib/widget/viewer_detail_pane.dart
2023-05-21 21:54:24 +08:00

559 lines
18 KiB
Dart

import 'dart:async';
import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/controller/account_controller.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/double_extension.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/exif_extension.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/location_util.dart' as location_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/platform/features.dart' as features;
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/inflate_file_descriptor.dart';
import 'package:nc_photos/use_case/list_file_tag.dart';
import 'package:nc_photos/use_case/update_property.dart';
import 'package:nc_photos/widget/about_geocoding_dialog.dart';
import 'package:nc_photos/widget/animated_visibility.dart';
import 'package:nc_photos/widget/gps_map.dart';
import 'package:nc_photos/widget/handler/add_selection_to_collection_handler.dart';
import 'package:nc_photos/widget/list_tile_center_leading.dart';
import 'package:nc_photos/widget/photo_date_time_edit_dialog.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:path/path.dart' as path_lib;
import 'package:tuple/tuple.dart';
part 'viewer_detail_pane.g.dart';
class ViewerSingleCollectionData {
const ViewerSingleCollectionData(this.collection, this.item);
final Collection collection;
final CollectionItem item;
}
class ViewerDetailPane extends StatefulWidget {
const ViewerDetailPane({
Key? key,
required this.account,
required this.fd,
this.fromCollection,
required this.onRemoveFromCollectionPressed,
required this.onArchivePressed,
required this.onUnarchivePressed,
this.onSlideshowPressed,
}) : super(key: key);
@override
createState() => _ViewerDetailPaneState();
final Account account;
final FileDescriptor fd;
/// Data of the collection this file belongs to, or null
final ViewerSingleCollectionData? fromCollection;
final void Function(BuildContext context) onRemoveFromCollectionPressed;
final void Function(BuildContext context) onArchivePressed;
final void Function(BuildContext context) onUnarchivePressed;
final VoidCallback? onSlideshowPressed;
}
@npLog
class _ViewerDetailPaneState extends State<ViewerDetailPane> {
_ViewerDetailPaneState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
assert(InflateFileDescriptor.require(c));
_c = c;
}
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.fileRepo) &&
DiContainer.has(c, DiType.albumRepo);
@override
initState() {
_log.info("[initState] File: ${widget.fd.fdPath}");
super.initState();
_dateTime = widget.fd.fdDateTime.toLocal();
_initFile();
}
Future<void> _initFile() async {
_file =
(await InflateFileDescriptor(_c)(widget.account, [widget.fd])).first;
_log.fine("[_initFile] File inflated");
// update file
if (mounted) {
setState(() {});
} else {
return;
}
if (_file!.metadata == null) {
_log.info("[initState] Metadata missing in File");
} else {
_log.info("[initState] Metadata exists in File");
if (_file!.metadata!.exif != null) {
_initMetadata();
}
}
await _initTags();
// update tages
if (mounted) {
setState(() {});
} else {
return;
}
// postpone loading map to improve responsiveness
unawaited(Future.delayed(const Duration(milliseconds: 750)).then((_) {
if (mounted) {
setState(() {
_shouldBlockGpsMap = false;
});
}
}));
}
@override
build(BuildContext context) {
final dateStr = DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY,
Localizations.localeOf(context).languageCode)
.format(_dateTime);
final timeStr = DateFormat(DateFormat.HOUR_MINUTE,
Localizations.localeOf(context).languageCode)
.format(_dateTime);
return Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_file != null) ...[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
if (_canRemoveFromAlbum)
_DetailPaneButton(
icon: Icons.remove_outlined,
label: L10n.global().removeFromAlbumTooltip,
onPressed: () =>
widget.onRemoveFromCollectionPressed(context),
),
if (_canSetCover)
_DetailPaneButton(
icon: Icons.photo_album_outlined,
label: L10n.global().useAsAlbumCoverTooltip,
onPressed: () => _onSetAlbumCoverPressed(context),
),
_DetailPaneButton(
icon: Icons.add,
label: L10n.global().addItemToCollectionTooltip,
onPressed: () => _onAddToAlbumPressed(context),
),
if (widget.fd.fdIsArchived == true)
_DetailPaneButton(
icon: Icons.unarchive_outlined,
label: L10n.global().unarchiveTooltip,
onPressed: () => widget.onUnarchivePressed(context),
)
else
_DetailPaneButton(
icon: Icons.archive_outlined,
label: L10n.global().archiveTooltip,
onPressed: () => widget.onArchivePressed(context),
),
_DetailPaneButton(
icon: Icons.slideshow_outlined,
label: L10n.global().slideshowTooltip,
onPressed: widget.onSlideshowPressed,
),
],
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Divider(),
),
],
ListTile(
leading: const ListTileCenterLeading(
child: Icon(Icons.image_outlined),
),
title: Text(path_lib.basenameWithoutExtension(widget.fd.fdPath)),
subtitle: Text(widget.fd.strippedPath),
),
if (_file != null) ...[
if (!_file!.isOwned(widget.account.userId))
ListTile(
leading: const ListTileCenterLeading(
child: Icon(
Icons.share_outlined,
),
),
title:
Text(_file!.ownerDisplayName ?? _file!.ownerId!.toString()),
subtitle: Text(L10n.global().fileSharedByDescription),
),
if (_tags.isNotEmpty)
ListTile(
leading: const Icon(Icons.local_offer_outlined),
title: SizedBox(
height: 40,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _tags.length,
itemBuilder: (context, index) => FilterChip(
elevation: 1,
pressElevation: 1,
showCheckmark: false,
visualDensity: VisualDensity.compact,
selected: true,
selectedColor: Theme.of(context)
.elevate(Theme.of(context).colorScheme.surface, 5),
label: Text(_tags[index]),
onSelected: (_) {},
),
separatorBuilder: (context, index) =>
const SizedBox(width: 8),
),
),
),
],
ListTile(
leading: const Icon(Icons.calendar_today_outlined),
title: Text("$dateStr $timeStr"),
trailing: _file == null ? null : const Icon(Icons.edit_outlined),
onTap: _file == null ? null : () => _onDateTimeTap(context),
),
if (_file != null) ...[
if (_file!.metadata?.imageWidth != null &&
_file!.metadata?.imageHeight != null)
ListTile(
leading: const ListTileCenterLeading(
child: Icon(Icons.aspect_ratio),
),
title: Text(
"${_file!.metadata!.imageWidth} x ${_file!.metadata!.imageHeight}"),
subtitle: Text(_buildSizeSubtitle()),
)
else
ListTile(
leading: const Icon(Icons.aspect_ratio),
title: Text(_byteSizeToString(_file!.contentLength ?? 0)),
),
if (_model != null)
ListTile(
leading: const ListTileCenterLeading(
child: Icon(Icons.camera_outlined),
),
title: Text(_model!),
subtitle: _buildCameraSubtitle()
.run((s) => s.isNotEmpty ? Text(s) : null),
),
if (_location?.name != null)
ListTile(
leading: const ListTileCenterLeading(
child: Icon(Icons.location_on_outlined),
),
title: Text(L10n.global().gpsPlaceText(_location!.name!)),
subtitle: _location!.toSubtitle()?.run((obj) => Text(obj)),
trailing: const Icon(Icons.info_outline),
onTap: () {
showDialog(
context: context,
builder: (_) => const AboutGeocodingDialog(),
);
},
),
if (features.isSupportMapView && _gps != null)
AnimatedVisibility(
opacity: _shouldBlockGpsMap ? 0 : 1,
curve: Curves.easeInOut,
duration: k.animationDurationNormal,
child: SizedBox(
height: 256,
child: GpsMap(
center: _gps!,
zoom: 16,
onTap: _onMapTap,
),
),
),
],
],
),
);
}
/// Convert EXIF data to readable format
void _initMetadata() {
assert(_file != null);
final exif = _file!.metadata!.exif!;
_log.info("[_initMetadata] $exif");
if (exif.make != null && exif.model != null) {
_model = "${exif.make} ${exif.model}";
}
if (exif.fNumber != null) {
_fNumber = exif.fNumber!.toDouble();
}
if (exif.exposureTime != null) {
if (exif.exposureTime!.denominator == 1) {
_exposureTime = exif.exposureTime!.numerator.toString();
} else {
_exposureTime = exif.exposureTime.toString();
}
}
if (exif.focalLength != null) {
_focalLength = exif.focalLength!.toDouble();
}
if (exif.isoSpeedRatings != null) {
_isoSpeedRatings = exif.isoSpeedRatings!;
}
final lat = exif.gpsLatitudeDeg;
final lng = exif.gpsLongitudeDeg;
if (lat != null && lng != null) {
_log.fine("GPS: ($lat, $lng)");
_gps = Tuple2(lat, lng);
_location = _file!.location;
}
}
Future<void> _initTags() async {
assert(_file != null);
final c = KiwiContainer().resolve<DiContainer>();
try {
final tags = await ListFileTag(c)(widget.account, _file!);
_tags.addAll(tags.map((t) => t.displayName));
} catch (e, stackTrace) {
_log.shout("[_initTags] Failed while ListFileTag", e, stackTrace);
}
}
String _buildSizeSubtitle() {
String sizeSubStr = "";
const space = " ";
if (_file!.metadata?.imageWidth != null &&
_file!.metadata?.imageHeight != null) {
final pixelCount =
_file!.metadata!.imageWidth! * _file!.metadata!.imageHeight!;
if (pixelCount >= 500000) {
final mpCount = pixelCount / 1000000.0;
sizeSubStr += L10n.global().megapixelCount(mpCount.toStringAsFixed(1));
sizeSubStr += space;
}
sizeSubStr += _byteSizeToString(_file!.contentLength ?? 0);
}
return sizeSubStr;
}
String _buildCameraSubtitle() {
String cameraSubStr = "";
const space = " ";
if (_fNumber != null) {
cameraSubStr += "f/${_fNumber!.toStringAsFixed(1)}$space";
}
if (_exposureTime != null) {
cameraSubStr += L10n.global().secondCountSymbol(_exposureTime!);
cameraSubStr += space;
}
if (_focalLength != null) {
cameraSubStr += L10n.global()
.millimeterCountSymbol(_focalLength!.toStringAsFixedTruncated(2));
cameraSubStr += space;
}
if (_isoSpeedRatings != null) {
cameraSubStr += "ISO$_isoSpeedRatings$space";
}
cameraSubStr = cameraSubStr.trim();
return cameraSubStr;
}
Future<void> _onSetAlbumCoverPressed(BuildContext context) async {
assert(_file != null);
assert(widget.fromCollection != null);
_log.info(
"[_onSetAlbumCoverPressed] Set '${widget.fd.fdPath}' as album cover for '${widget.fromCollection!.collection.name}'");
try {
await context.read<AccountController>().collectionsController.edit(
widget.fromCollection!.collection,
cover: OrNull(_file!),
);
} catch (e, stackTrace) {
_log.shout("[_onSetAlbumCoverPressed] Failed while updating album", e,
stackTrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().setCollectionCoverFailureNotification),
duration: k.snackBarDurationNormal,
));
}
}
Future<void> _onAddToAlbumPressed(BuildContext context) {
assert(_file != null);
return const AddSelectionToCollectionHandler()(
context: context,
selection: [_file!],
clearSelection: () {},
);
}
void _onMapTap() {
if (platform_k.isAndroid) {
final intent = AndroidIntent(
action: "action_view",
data: Uri.encodeFull("geo:${_gps!.item1},${_gps!.item2}?z=16"),
);
intent.launch();
}
}
void _onDateTimeTap(BuildContext context) {
assert(_file != null);
showDialog(
context: context,
builder: (context) => PhotoDateTimeEditDialog(initialDateTime: _dateTime),
).then((value) async {
if (value == null || value is! DateTime) {
return;
}
try {
await UpdateProperty(_c.fileRepo)
.updateOverrideDateTime(widget.account, _file!, value);
if (mounted) {
setState(() {
_dateTime = value;
});
}
} catch (e, stacktrace) {
_log.shout(
"[_onDateTimeTap] Failed while updateOverrideDateTime: ${logFilename(widget.fd.fdPath)}",
e,
stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().updateDateTimeFailureNotification),
duration: k.snackBarDurationNormal,
));
}
}).catchError((e, stacktrace) {
_log.shout("[_onDateTimeTap] Failed while showDialog", e, stacktrace);
});
}
late final DiContainer _c;
File? _file;
late DateTime _dateTime;
// EXIF data
String? _model;
double? _fNumber;
String? _exposureTime;
double? _focalLength;
int? _isoSpeedRatings;
Tuple2<double, double>? _gps;
ImageLocation? _location;
final _tags = <String>[];
var _shouldBlockGpsMap = true;
late final bool _canRemoveFromAlbum = widget.fromCollection?.run((d) =>
CollectionAdapter.of(_c, widget.account, d.collection)
.isItemRemovable(widget.fromCollection!.item)) ??
false;
late final bool _canSetCover = widget.fromCollection?.run((d) =>
CollectionAdapter.of(_c, widget.account, d.collection)
.isPermitted(CollectionCapability.manualCover)) ??
false;
}
class _DetailPaneButton extends StatelessWidget {
const _DetailPaneButton({
Key? key,
required this.icon,
required this.label,
required this.onPressed,
}) : super(key: key);
@override
build(BuildContext context) {
return TextButton(
onPressed: onPressed,
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(Theme.of(context).colorScheme.onSurface),
),
child: SizedBox(
width: 96,
height: 96,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 16, 8, 0),
child: Column(
children: [
Icon(icon),
const SizedBox(height: 4),
Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
),
);
}
final IconData icon;
final String label;
final VoidCallback? onPressed;
}
String _byteSizeToString(int byteSize) {
const units = ["B", "KB", "MB", "GB"];
var remain = byteSize.toDouble();
int i = 0;
while (i < units.length) {
final next = remain / 1024;
if (next < 1) {
break;
}
remain = next;
++i;
}
return "${remain.toStringAsFixed(2)}${units[i]}";
}
extension on ImageLocation {
String? toSubtitle() {
if (countryCode == null) {
return null;
} else if (admin1 == null) {
return location_util.alpha2CodeToName(countryCode!);
} else if (admin2 == null) {
return "$admin1, ${location_util.alpha2CodeToName(countryCode!)}";
} else {
return "$admin2, $admin1, ${location_util.alpha2CodeToName(countryCode!)}";
}
}
}