nc-photos/lib/widget/viewer_detail_pane.dart

598 lines
20 KiB
Dart
Raw Normal View History

2021-08-13 16:10:20 +02:00
import 'dart:async';
import 'package:android_intent_plus/android_intent.dart';
import 'package:exifdart/exifdart.dart';
2021-04-10 06:28:12 +02:00
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
2021-07-25 07:00:38 +02:00
import 'package:nc_photos/app_localizations.dart';
2021-09-04 14:35:04 +02:00
import 'package:nc_photos/debug_util.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/double_extension.dart';
import 'package:nc_photos/entity/album.dart';
2021-08-13 22:18:35 +02:00
import 'package:nc_photos/entity/album/cover_provider.dart';
2021-07-05 09:54:01 +02:00
import 'package:nc_photos/entity/album/item.dart';
2021-06-24 18:26:56 +02:00
import 'package:nc_photos/entity/album/provider.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/entity/file.dart';
2021-05-24 09:09:25 +02:00
import 'package:nc_photos/entity/file/data_source.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/k.dart' as k;
2021-04-19 18:14:32 +02:00
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
2021-08-31 14:00:55 +02:00
import 'package:nc_photos/notified_action.dart';
import 'package:nc_photos/platform/features.dart' as features;
2021-04-29 17:42:44 +02:00
import 'package:nc_photos/platform/k.dart' as platform_k;
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/update_album.dart';
2021-06-21 12:39:17 +02:00
import 'package:nc_photos/use_case/update_property.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/widget/album_picker_dialog.dart';
2021-06-21 12:39:17 +02:00
import 'package:nc_photos/widget/photo_date_time_edit_dialog.dart';
2021-04-10 06:28:12 +02:00
import 'package:path/path.dart';
2021-04-19 18:14:32 +02:00
import 'package:tuple/tuple.dart';
2021-04-10 06:28:12 +02:00
class ViewerDetailPane extends StatefulWidget {
const ViewerDetailPane({
2021-07-23 22:05:57 +02:00
Key? key,
required this.account,
required this.file,
2021-08-13 22:18:35 +02:00
this.album,
2021-04-10 06:28:12 +02:00
}) : super(key: key);
@override
createState() => _ViewerDetailPaneState();
final Account account;
final File file;
2021-08-13 22:18:35 +02:00
/// The album this file belongs to, or null
final Album? album;
2021-04-10 06:28:12 +02:00
}
class _ViewerDetailPaneState extends State<ViewerDetailPane> {
@override
initState() {
super.initState();
2021-06-21 12:39:17 +02:00
_dateTime = widget.file.bestDateTime.toLocal();
2021-04-10 06:28:12 +02:00
if (widget.file.metadata == null) {
_log.info("[initState] Metadata missing in File");
} else {
_log.info("[initState] Metadata exists in File");
2021-07-23 22:05:57 +02:00
if (widget.file.metadata!.exif != null) {
2021-06-20 13:49:08 +02:00
_initMetadata();
}
2021-04-10 06:28:12 +02:00
}
}
@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);
2021-04-10 06:28:12 +02:00
String sizeSubStr = "";
const space = " ";
2021-06-20 13:49:08 +02:00
if (widget.file.metadata?.imageWidth != null &&
widget.file.metadata?.imageHeight != null) {
2021-07-23 22:05:57 +02:00
final pixelCount = widget.file.metadata!.imageWidth! *
widget.file.metadata!.imageHeight!;
2021-04-10 06:28:12 +02:00
if (pixelCount >= 500000) {
final mpCount = pixelCount / 1000000.0;
sizeSubStr += L10n.global().megapixelCount(mpCount.toStringAsFixed(1));
2021-04-10 06:28:12 +02:00
sizeSubStr += space;
}
2021-07-23 22:05:57 +02:00
sizeSubStr += _byteSizeToString(widget.file.contentLength ?? 0);
2021-04-10 06:28:12 +02:00
}
String cameraSubStr = "";
if (_fNumber != null) {
2021-07-23 22:05:57 +02:00
cameraSubStr += "f/${_fNumber!.toStringAsFixed(1)}$space";
2021-04-10 06:28:12 +02:00
}
if (_exposureTime != null) {
cameraSubStr += L10n.global().secondCountSymbol(_exposureTime!);
2021-04-10 06:28:12 +02:00
cameraSubStr += space;
}
if (_focalLength != null) {
cameraSubStr += L10n.global()
2021-07-23 22:05:57 +02:00
.millimeterCountSymbol(_focalLength!.toStringAsFixedTruncated(2));
2021-04-10 06:28:12 +02:00
cameraSubStr += space;
}
if (_isoSpeedRatings != null) {
cameraSubStr += "ISO$_isoSpeedRatings$space";
}
cameraSubStr = cameraSubStr.trim();
2021-06-21 12:39:17 +02:00
return Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
if (widget.album != null &&
widget.album!.albumFile?.isOwned(widget.account.username) ==
true &&
widget.album!.provider is AlbumStaticProvider)
_DetailPaneButton(
icon: Icons.remove_outlined,
label: L10n.global().removeFromAlbumTooltip,
onPressed: () => _onRemoveFromAlbumPressed(context),
),
if (widget.album != null &&
widget.album!.albumFile?.isOwned(widget.account.username) ==
true)
_DetailPaneButton(
icon: Icons.photo_album_outlined,
label: L10n.global().useAsAlbumCoverTooltip,
onPressed: () => _onSetAlbumCoverPressed(context),
),
2021-08-21 19:58:17 +02:00
_DetailPaneButton(
icon: Icons.playlist_add_outlined,
label: L10n.global().addToAlbumTooltip,
onPressed: () => _onAddToAlbumPressed(context),
2021-08-21 19:58:17 +02:00
),
if (widget.file.isArchived == true)
_DetailPaneButton(
icon: Icons.unarchive_outlined,
label: L10n.global().unarchiveTooltip,
onPressed: () => _onUnarchivePressed(context),
)
else
_DetailPaneButton(
icon: Icons.archive_outlined,
label: L10n.global().archiveTooltip,
onPressed: () => _onArchivePressed(context),
),
],
),
2021-04-17 10:59:16 +02:00
),
2021-06-21 12:39:17 +02:00
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: const Divider(),
2021-04-17 10:59:16 +02:00
),
2021-04-10 06:28:12 +02:00
ListTile(
2021-08-26 19:26:51 +02:00
leading: Container(
height: double.infinity,
child: Icon(
Icons.image_outlined,
color: AppTheme.getSecondaryTextColor(context),
),
2021-04-17 10:59:16 +02:00
),
2021-06-21 12:39:17 +02:00
title: Text(basenameWithoutExtension(widget.file.path)),
subtitle: Text(widget.file.strippedPath),
),
2021-08-26 19:26:31 +02:00
if (!widget.file.isOwned(widget.account.username))
ListTile(
leading: Container(
height: double.infinity,
child: Icon(
Icons.share_outlined,
color: AppTheme.getSecondaryTextColor(context),
),
),
title: Text(widget.file.ownerId!),
subtitle: Text(L10n.global().fileSharedByDescription),
2021-08-26 19:26:31 +02:00
),
2021-04-10 06:28:12 +02:00
ListTile(
2021-04-17 10:59:16 +02:00
leading: Icon(
2021-06-21 12:39:17 +02:00
Icons.calendar_today_outlined,
2021-04-17 10:59:16 +02:00
color: AppTheme.getSecondaryTextColor(context),
),
2021-06-21 12:39:17 +02:00
title: Text("$dateStr $timeStr"),
trailing: Icon(
Icons.edit_outlined,
2021-04-17 10:59:16 +02:00
color: AppTheme.getSecondaryTextColor(context),
),
2021-06-21 12:39:17 +02:00
onTap: () => _onDateTimeTap(context),
2021-04-10 06:28:12 +02:00
),
2021-06-21 12:39:17 +02:00
if (widget.file.metadata?.imageWidth != null &&
widget.file.metadata?.imageHeight != null)
ListTile(
2021-08-28 18:31:19 +02:00
leading: Container(
height: double.infinity,
child: Icon(
Icons.aspect_ratio,
color: AppTheme.getSecondaryTextColor(context),
),
2021-06-21 12:39:17 +02:00
),
title: Text(
2021-07-23 22:05:57 +02:00
"${widget.file.metadata!.imageWidth} x ${widget.file.metadata!.imageHeight}"),
2021-06-21 12:39:17 +02:00
subtitle: Text(sizeSubStr),
)
else
ListTile(
2021-08-28 18:31:19 +02:00
leading: Container(
height: double.infinity,
child: Icon(
Icons.aspect_ratio,
color: AppTheme.getSecondaryTextColor(context),
),
2021-06-21 12:39:17 +02:00
),
2021-07-23 22:05:57 +02:00
title: Text(_byteSizeToString(widget.file.contentLength ?? 0)),
),
2021-06-21 12:39:17 +02:00
if (_model != null)
ListTile(
2021-08-28 18:31:19 +02:00
leading: Container(
height: double.infinity,
child: Icon(
Icons.camera_outlined,
color: AppTheme.getSecondaryTextColor(context),
),
2021-06-21 12:39:17 +02:00
),
2021-07-23 22:05:57 +02:00
title: Text(_model!),
2021-06-21 12:39:17 +02:00
subtitle: cameraSubStr.isNotEmpty ? Text(cameraSubStr) : null,
),
if (features.isSupportMapView && _gps != null)
SizedBox(
height: 256,
child: platform.Map(
2021-07-23 22:05:57 +02:00
center: _gps!,
2021-06-21 12:39:17 +02:00
zoom: 16,
onTap: _onMapTap,
),
),
],
),
2021-04-10 06:28:12 +02:00
);
}
2021-06-20 13:49:08 +02:00
/// Convert EXIF data to readable format
void _initMetadata() {
2021-07-23 22:05:57 +02:00
final exif = widget.file.metadata!.exif!;
2021-06-20 13:49:08 +02:00
_log.info("[_initMetadata] $exif");
if (exif.make != null && exif.model != null) {
_model = "${exif.make} ${exif.model}";
}
if (exif.fNumber != null) {
2021-07-23 22:05:57 +02:00
_fNumber = exif.fNumber!.toDouble();
2021-06-20 13:49:08 +02:00
}
if (exif.exposureTime != null) {
2021-07-23 22:05:57 +02:00
if (exif.exposureTime!.denominator == 1) {
_exposureTime = exif.exposureTime!.numerator.toString();
2021-06-20 13:49:08 +02:00
} else {
_exposureTime = exif.exposureTime.toString();
}
}
if (exif.focalLength != null) {
2021-07-23 22:05:57 +02:00
_focalLength = exif.focalLength!.toDouble();
2021-06-20 13:49:08 +02:00
}
if (exif.isoSpeedRatings != null) {
2021-07-23 22:05:57 +02:00
_isoSpeedRatings = exif.isoSpeedRatings!;
2021-06-20 13:49:08 +02:00
}
if (exif.gpsLatitudeRef != null &&
exif.gpsLatitude != null &&
exif.gpsLongitudeRef != null &&
exif.gpsLongitude != null) {
2021-07-23 22:05:57 +02:00
final lat = _gpsDmsToDouble(exif.gpsLatitude!) *
2021-06-20 13:49:08 +02:00
(exif.gpsLatitudeRef == "S" ? -1 : 1);
2021-07-23 22:05:57 +02:00
final lng = _gpsDmsToDouble(exif.gpsLongitude!) *
2021-06-20 13:49:08 +02:00
(exif.gpsLongitudeRef == "W" ? -1 : 1);
_log.fine("GPS: ($lat, $lng)");
_gps = Tuple2(lat, lng);
}
}
2021-08-21 19:58:17 +02:00
Future<void> _onRemoveFromAlbumPressed(BuildContext context) async {
assert(widget.album!.provider is AlbumStaticProvider);
2021-08-31 14:00:55 +02:00
await NotifiedAction(
2021-08-21 19:58:17 +02:00
() async {
final albumRepo = AlbumRepo(AlbumCachedDataSource());
try {
final newItems =
AlbumStaticProvider.of(widget.album!).items.where((element) {
if (element is AlbumFileItem) {
return element.file.path != widget.file.path;
} else {
return true;
}
}).toList();
await UpdateAlbum(albumRepo)(
widget.account,
widget.album!.copyWith(
provider: AlbumStaticProvider(
items: newItems,
),
));
if (mounted) {
Navigator.of(context).pop();
}
} catch (e, stackTrace) {
_log.shout("[_onRemoveFromAlbumPressed] Failed while updating album",
e, stackTrace);
rethrow;
}
},
2021-08-31 14:00:55 +02:00
null,
L10n.global().removeSelectedFromAlbumSuccessNotification(1),
failureText: L10n.global().removeSelectedFromAlbumFailureNotification,
2021-08-31 14:00:55 +02:00
)();
2021-08-21 19:58:17 +02:00
}
2021-08-13 22:18:35 +02:00
Future<void> _onSetAlbumCoverPressed(BuildContext context) async {
assert(widget.album != null);
_log.info(
"[_onSetAlbumCoverPressed] Set '${widget.file.path}' as album cover for '${widget.album!.name}'");
2021-08-31 14:00:55 +02:00
await NotifiedAction(
2021-08-13 22:18:35 +02:00
() async {
final albumRepo = AlbumRepo(AlbumCachedDataSource());
try {
await UpdateAlbum(albumRepo).call(
widget.account,
widget.album!.copyWith(
coverProvider: AlbumManualCoverProvider(
coverFile: widget.file,
),
));
} catch (e, stackTrace) {
_log.shout("[_onSetAlbumCoverPressed] Failed while updating album", e,
stackTrace);
rethrow;
}
},
2021-08-31 14:00:55 +02:00
L10n.global().setAlbumCoverProcessingNotification,
L10n.global().setAlbumCoverSuccessNotification,
failureText: L10n.global().setAlbumCoverFailureNotification,
2021-08-31 14:00:55 +02:00
)();
2021-08-13 22:18:35 +02:00
}
2021-04-10 06:28:12 +02:00
void _onAddToAlbumPressed(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlbumPickerDialog(
account: widget.account,
),
).then((value) {
if (value == null) {
// user cancelled the dialog
} else if (value is Album) {
2021-04-18 13:32:10 +02:00
_log.info("[_onAddToAlbumPressed] Album picked: ${value.name}");
_addToAlbum(value).then((_) {
2021-04-10 06:28:12 +02:00
SnackBarManager().showSnackBar(SnackBar(
content:
Text(L10n.global().addToAlbumSuccessNotification(value.name)),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
}).catchError((_) {});
} else {
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().addToAlbumFailureNotification),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
}
}).catchError((e, stacktrace) {
_log.severe(
"[_onAddToAlbumPressed] Failed while showDialog", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text("${L10n.global().addToAlbumFailureNotification}: "
"${exception_util.toUserString(e)}"),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
});
}
2021-08-13 16:10:20 +02:00
Future<void> _onArchivePressed(BuildContext context) async {
_log.info("[_onArchivePressed] Archive file: ${widget.file.path}");
2021-08-31 14:00:55 +02:00
await NotifiedAction(
2021-08-13 16:10:20 +02:00
() async {
final fileRepo = FileRepo(FileCachedDataSource());
try {
await UpdateProperty(fileRepo)
.updateIsArchived(widget.account, widget.file, true);
if (mounted) {
Navigator.of(context).pop();
}
2021-08-13 16:10:20 +02:00
} catch (e, stackTrace) {
_log.shout(
"[_onArchivePressed] Failed while archiving file" +
2021-09-04 14:35:04 +02:00
(shouldLogFileName ? ": ${widget.file.path}" : ""),
2021-08-13 16:10:20 +02:00
e,
stackTrace);
rethrow;
}
},
2021-08-31 14:00:55 +02:00
L10n.global().archiveSelectedProcessingNotification(1),
L10n.global().archiveSelectedSuccessNotification,
failureText: L10n.global().archiveSelectedFailureNotification(1),
2021-08-31 14:00:55 +02:00
)();
2021-08-13 16:10:20 +02:00
}
void _onUnarchivePressed(BuildContext context) async {
_log.info("[_onUnarchivePressed] Unarchive file: ${widget.file.path}");
2021-08-31 14:00:55 +02:00
await NotifiedAction(
2021-08-13 16:10:20 +02:00
() async {
final fileRepo = FileRepo(FileCachedDataSource());
try {
await UpdateProperty(fileRepo)
.updateIsArchived(widget.account, widget.file, false);
if (mounted) {
Navigator.of(context).pop();
}
2021-08-13 16:10:20 +02:00
} catch (e, stackTrace) {
_log.shout(
"[_onUnarchivePressed] Failed while archiving file" +
2021-09-04 14:35:04 +02:00
(shouldLogFileName ? ": ${widget.file.path}" : ""),
2021-08-13 16:10:20 +02:00
e,
stackTrace);
rethrow;
}
},
2021-08-31 14:00:55 +02:00
L10n.global().unarchiveSelectedProcessingNotification(1),
L10n.global().unarchiveSelectedSuccessNotification,
failureText: L10n.global().unarchiveSelectedFailureNotification(1),
2021-08-31 14:00:55 +02:00
)();
2021-08-13 16:10:20 +02:00
}
void _onMapTap() {
2021-04-29 17:42:44 +02:00
if (platform_k.isAndroid) {
final intent = AndroidIntent(
action: "action_view",
2021-07-23 22:05:57 +02:00
data: Uri.encodeFull("geo:${_gps!.item1},${_gps!.item2}?z=16"),
);
intent.launch();
}
}
2021-06-21 12:39:17 +02:00
void _onDateTimeTap(BuildContext context) {
showDialog(
context: context,
builder: (context) => PhotoDateTimeEditDialog(initialDateTime: _dateTime),
).then((value) async {
if (value == null || value is! DateTime) {
return;
}
final fileRepo = FileRepo(FileCachedDataSource());
try {
await UpdateProperty(fileRepo)
.updateOverrideDateTime(widget.account, widget.file, value);
if (mounted) {
setState(() {
_dateTime = value;
});
}
2021-06-21 12:39:17 +02:00
} catch (e, stacktrace) {
_log.shout(
"[_onDateTimeTap] Failed while updateOverrideDateTime" +
2021-09-04 14:35:04 +02:00
(shouldLogFileName ? ": ${widget.file.path}" : ""),
2021-06-21 12:39:17 +02:00
e,
stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().updateDateTimeFailureNotification),
2021-06-21 12:39:17 +02:00
duration: k.snackBarDurationNormal,
));
}
}).catchError((e, stacktrace) {
_log.shout("[_onDateTimeTap] Failed while showDialog", e, stacktrace);
});
}
static double _gpsDmsToDouble(List<Rational> dms) {
double product = dms[0].toDouble();
if (dms.length > 1) {
product += dms[1].toDouble() / 60;
}
if (dms.length > 2) {
product += dms[2].toDouble() / 3600;
}
return product;
2021-04-10 06:28:12 +02:00
}
Future<void> _addToAlbum(Album album) async {
2021-06-24 18:26:56 +02:00
assert(album.provider is AlbumStaticProvider);
2021-04-10 06:28:12 +02:00
try {
final albumRepo = AlbumRepo(AlbumCachedDataSource());
final newItem = AlbumFileItem(file: widget.file);
2021-06-24 18:26:56 +02:00
if (AlbumStaticProvider.of(album)
.items
.whereType<AlbumFileItem>()
.containsIf(newItem, (a, b) => a.file.path == b.file.path)) {
// already added, do nothing
_log.info("[_addToAlbum] File already in album: ${widget.file.path}");
SnackBarManager().showSnackBar(SnackBar(
content: Text("${L10n.global().addToAlbumAlreadyAddedNotification}"),
duration: k.snackBarDurationNormal,
));
return Future.error(ArgumentError("File already in album"));
}
2021-04-10 06:28:12 +02:00
await UpdateAlbum(albumRepo)(
widget.account,
album.copyWith(
2021-06-24 18:26:56 +02:00
provider: AlbumStaticProvider(
items: [
AlbumFileItem(file: widget.file),
...AlbumStaticProvider.of(album).items,
2021-06-24 18:26:56 +02:00
],
),
2021-04-10 06:28:12 +02:00
));
} catch (e, stacktrace) {
2021-04-27 22:06:16 +02:00
_log.shout("[_addToAlbum] Failed while updating album", e, stacktrace);
2021-04-10 06:28:12 +02:00
SnackBarManager().showSnackBar(SnackBar(
content: Text("${L10n.global().addToAlbumFailureNotification}: "
"${exception_util.toUserString(e)}"),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
rethrow;
}
}
2021-07-23 22:05:57 +02:00
late DateTime _dateTime;
2021-04-10 06:28:12 +02:00
// EXIF data
2021-07-23 22:05:57 +02:00
String? _model;
double? _fNumber;
String? _exposureTime;
double? _focalLength;
int? _isoSpeedRatings;
Tuple2<double, double>? _gps;
2021-04-10 06:28:12 +02:00
static final _log =
Logger("widget.viewer_detail_pane._ViewerDetailPaneState");
}
class _DetailPaneButton extends StatelessWidget {
2021-07-23 22:05:57 +02:00
const _DetailPaneButton({
Key? key,
required this.icon,
required this.label,
required this.onPressed,
}) : super(key: key);
2021-04-10 06:28:12 +02:00
@override
build(BuildContext context) {
return TextButton(
onPressed: onPressed,
child: SizedBox(
width: 96,
height: 96,
child: Padding(
2021-08-13 22:19:07 +02:00
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
2021-04-10 06:28:12 +02:00
child: Column(
children: [
2021-04-17 10:59:16 +02:00
Icon(
icon,
color: AppTheme.getSecondaryTextColor(context),
2021-04-17 10:59:16 +02:00
),
2021-04-10 06:28:12 +02:00
const SizedBox(height: 4),
Text(
label,
textAlign: TextAlign.center,
2021-04-17 10:59:16 +02:00
style: TextStyle(
fontSize: 12,
color: AppTheme.getSecondaryTextColor(context),
),
2021-04-10 06:28:12 +02:00
),
],
),
),
),
);
}
final IconData icon;
final String label;
2021-07-23 22:05:57 +02:00
final VoidCallback? onPressed;
2021-04-10 06:28:12 +02:00
}
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]}";
}