mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-03-13 18:58:53 +01:00
Customize date/time of a file
This commit is contained in:
parent
deacacdd96
commit
b47cb5c63c
11 changed files with 505 additions and 66 deletions
|
@ -219,6 +219,7 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
|||
if (!ev.hasAnyProperties([
|
||||
FilePropertyUpdatedEvent.propMetadata,
|
||||
FilePropertyUpdatedEvent.propIsArchived,
|
||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||
])) {
|
||||
// not interested
|
||||
return;
|
||||
|
@ -234,7 +235,10 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
|||
if (_successivePropertyUpdatedCount % 10 == 0) {
|
||||
add(_ScanDirBlocExternalEvent());
|
||||
} else {
|
||||
if (ev.hasAnyProperties([FilePropertyUpdatedEvent.propIsArchived])) {
|
||||
if (ev.hasAnyProperties([
|
||||
FilePropertyUpdatedEvent.propIsArchived,
|
||||
FilePropertyUpdatedEvent.propOverrideDateTime,
|
||||
])) {
|
||||
_propertyUpdatedSubscription =
|
||||
Future.delayed(const Duration(seconds: 2)).asStream().listen((_) {
|
||||
add(_ScanDirBlocExternalEvent());
|
||||
|
|
|
@ -209,6 +209,7 @@ class File with EquatableMixin {
|
|||
this.ownerId,
|
||||
this.metadata,
|
||||
this.isArchived,
|
||||
this.overrideDateTime,
|
||||
}) : this.path = path.trimAny("/");
|
||||
|
||||
@override
|
||||
|
@ -253,6 +254,9 @@ class File with EquatableMixin {
|
|||
),
|
||||
),
|
||||
isArchived: json["isArchived"],
|
||||
overrideDateTime: json["overrideDateTime"] == null
|
||||
? null
|
||||
: DateTime.parse(json["overrideDateTime"]),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -293,6 +297,9 @@ class File with EquatableMixin {
|
|||
if (isArchived != null) {
|
||||
product += "isArchived: $isArchived, ";
|
||||
}
|
||||
if (overrideDateTime != null) {
|
||||
product += "overrideDateTime: $overrideDateTime, ";
|
||||
}
|
||||
return product + "}";
|
||||
}
|
||||
|
||||
|
@ -311,6 +318,8 @@ class File with EquatableMixin {
|
|||
if (ownerId != null) "ownerId": ownerId,
|
||||
if (metadata != null) "metadata": metadata.toJson(),
|
||||
if (isArchived != null) "isArchived": isArchived,
|
||||
if (overrideDateTime != null)
|
||||
"overrideDateTime": overrideDateTime.toUtc().toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -327,6 +336,7 @@ class File with EquatableMixin {
|
|||
String ownerId,
|
||||
OrNull<Metadata> metadata,
|
||||
OrNull<bool> isArchived,
|
||||
OrNull<DateTime> overrideDateTime,
|
||||
}) {
|
||||
return File(
|
||||
path: path ?? this.path,
|
||||
|
@ -341,6 +351,9 @@ class File with EquatableMixin {
|
|||
ownerId: ownerId ?? this.ownerId,
|
||||
metadata: metadata == null ? this.metadata : metadata.obj,
|
||||
isArchived: isArchived == null ? this.isArchived : isArchived.obj,
|
||||
overrideDateTime: overrideDateTime == null
|
||||
? this.overrideDateTime
|
||||
: overrideDateTime.obj,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -375,6 +388,7 @@ class File with EquatableMixin {
|
|||
ownerId,
|
||||
// metadata is handled separately, see [equals]
|
||||
isArchived,
|
||||
overrideDateTime,
|
||||
];
|
||||
|
||||
final String path;
|
||||
|
@ -391,11 +405,12 @@ class File with EquatableMixin {
|
|||
// metadata
|
||||
final Metadata metadata;
|
||||
final bool isArchived;
|
||||
final DateTime overrideDateTime;
|
||||
}
|
||||
|
||||
extension FileExtension on File {
|
||||
DateTime get bestDateTime {
|
||||
return metadata?.exif?.dateTimeOriginal ?? lastModified;
|
||||
return overrideDateTime ?? metadata?.exif?.dateTimeOriginal ?? lastModified;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -424,12 +439,14 @@ class FileRepo {
|
|||
File file, {
|
||||
OrNull<Metadata> metadata,
|
||||
OrNull<bool> isArchived,
|
||||
OrNull<DateTime> overrideDateTime,
|
||||
}) =>
|
||||
this.dataSrc.updateProperty(
|
||||
account,
|
||||
file,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
);
|
||||
|
||||
/// See [FileDataSource.copy]
|
||||
|
@ -486,6 +503,7 @@ abstract class FileDataSource {
|
|||
File f, {
|
||||
OrNull<Metadata> metadata,
|
||||
OrNull<bool> isArchived,
|
||||
OrNull<DateTime> overrideDateTime,
|
||||
});
|
||||
|
||||
/// Copy [f] to [destination]
|
||||
|
|
|
@ -46,6 +46,7 @@ class FileWebdavDataSource implements FileDataSource {
|
|||
customProperties: [
|
||||
"app:metadata",
|
||||
"app:is-archived",
|
||||
"app:override-date-time"
|
||||
],
|
||||
);
|
||||
if (!response.isGood) {
|
||||
|
@ -112,6 +113,7 @@ class FileWebdavDataSource implements FileDataSource {
|
|||
File f, {
|
||||
OrNull<Metadata> metadata,
|
||||
OrNull<bool> isArchived,
|
||||
OrNull<DateTime> overrideDateTime,
|
||||
}) async {
|
||||
_log.info("[updateProperty] ${f.path}");
|
||||
if (metadata?.obj != null && metadata.obj.fileEtag != f.etag) {
|
||||
|
@ -122,10 +124,13 @@ class FileWebdavDataSource implements FileDataSource {
|
|||
if (metadata?.obj != null)
|
||||
"app:metadata": jsonEncode(metadata.obj.toJson()),
|
||||
if (isArchived?.obj != null) "app:is-archived": isArchived.obj,
|
||||
if (overrideDateTime?.obj != null)
|
||||
"app:override-date-time": overrideDateTime.obj.toUtc().toIso8601String(),
|
||||
};
|
||||
final removeProps = [
|
||||
if (OrNull.isNull(metadata)) "app:metadata",
|
||||
if (OrNull.isNull(isArchived)) "app:is-archived",
|
||||
if (OrNull.isNull(overrideDateTime)) "app:override-date-time",
|
||||
];
|
||||
final response = await Api(account).files().proppatch(
|
||||
path: f.path,
|
||||
|
@ -251,6 +256,7 @@ class FileAppDbDataSource implements FileDataSource {
|
|||
File f, {
|
||||
OrNull<Metadata> metadata,
|
||||
OrNull<bool> isArchived,
|
||||
OrNull<DateTime> overrideDateTime,
|
||||
}) {
|
||||
_log.info("[updateProperty] ${f.path}");
|
||||
return AppDb.use((db) async {
|
||||
|
@ -266,6 +272,7 @@ class FileAppDbDataSource implements FileDataSource {
|
|||
return e.copyWith(
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
);
|
||||
} else {
|
||||
return e;
|
||||
|
@ -278,6 +285,7 @@ class FileAppDbDataSource implements FileDataSource {
|
|||
final newFile = f.copyWith(
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
);
|
||||
await fileDbStore.put(
|
||||
AppDbFileDbEntry.fromFile(account, newFile).toJson(),
|
||||
|
@ -414,6 +422,7 @@ class FileCachedDataSource implements FileDataSource {
|
|||
File f, {
|
||||
OrNull<Metadata> metadata,
|
||||
OrNull<bool> isArchived,
|
||||
OrNull<DateTime> overrideDateTime,
|
||||
}) async {
|
||||
await _remoteSrc
|
||||
.updateProperty(
|
||||
|
@ -421,12 +430,14 @@ class FileCachedDataSource implements FileDataSource {
|
|||
f,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
)
|
||||
.then((_) => _appDbSrc.updateProperty(
|
||||
account,
|
||||
f,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
));
|
||||
|
||||
// generate a new random token
|
||||
|
|
|
@ -67,6 +67,7 @@ class WebdavFileParser {
|
|||
String ownerId;
|
||||
Metadata metadata;
|
||||
bool isArchived;
|
||||
DateTime overrideDateTime;
|
||||
|
||||
for (final child in element.children.whereType<XmlElement>()) {
|
||||
if (child.matchQualifiedName("href",
|
||||
|
@ -99,6 +100,7 @@ class WebdavFileParser {
|
|||
ownerId = propParser.ownerId;
|
||||
metadata = propParser.metadata;
|
||||
isArchived = propParser.isArchived;
|
||||
overrideDateTime = propParser.overrideDateTime;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -115,6 +117,7 @@ class WebdavFileParser {
|
|||
ownerId: ownerId,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -174,6 +177,9 @@ class _PropParser {
|
|||
} else if (child.matchQualifiedName("is-archived",
|
||||
prefix: "com.nkming.nc_photos", namespaces: namespaces)) {
|
||||
_isArchived = child.innerText == "true";
|
||||
} else if (child.matchQualifiedName("override-date-time",
|
||||
prefix: "com.nkming.nc_photos", namespaces: namespaces)) {
|
||||
_overrideDateTime = DateTime.parse(child.innerText);
|
||||
}
|
||||
}
|
||||
// 2nd pass that depends on data in 1st pass
|
||||
|
@ -206,6 +212,7 @@ class _PropParser {
|
|||
String get ownerId => _ownerId;
|
||||
Metadata get metadata => _metadata;
|
||||
bool get isArchived => _isArchived;
|
||||
DateTime get overrideDateTime => _overrideDateTime;
|
||||
|
||||
final Map<String, String> namespaces;
|
||||
|
||||
|
@ -223,6 +230,7 @@ class _PropParser {
|
|||
String _ownerId;
|
||||
Metadata _metadata;
|
||||
bool _isArchived;
|
||||
DateTime _overrideDateTime;
|
||||
}
|
||||
|
||||
extension on XmlElement {
|
||||
|
|
|
@ -58,6 +58,7 @@ class FilePropertyUpdatedEvent {
|
|||
// Bit masks for properties field
|
||||
static const propMetadata = 0x01;
|
||||
static const propIsArchived = 0x02;
|
||||
static const propOverrideDateTime = 0x04;
|
||||
}
|
||||
|
||||
class FileRemovedEvent {
|
||||
|
|
|
@ -432,6 +432,25 @@
|
|||
"@mobileSelectRangeNotification": {
|
||||
"description": "Inform mobile user how to select items in range"
|
||||
},
|
||||
"updateDateTimeDialogTitle": "Modify date & time",
|
||||
"@updateDateTimeDialogTitle": {
|
||||
"description": "Dialog to modify the date & time of a file"
|
||||
},
|
||||
"dateSubtitle": "Date",
|
||||
"timeSubtitle": "Time",
|
||||
"dateYearInputHint": "Year",
|
||||
"dateMonthInputHint": "Month",
|
||||
"dateDayInputHint": "Day",
|
||||
"timeHourInputHint": "Hour",
|
||||
"timeMinuteInputHint": "Minute",
|
||||
"dateTimeInputInvalid": "Invalid value",
|
||||
"@dateTimeInputInvalid": {
|
||||
"description": "Invalid date/time input (e.g., non-numeric characters)"
|
||||
},
|
||||
"updateDateTimeFailureNotification": "Failed modifying date & time",
|
||||
"@updateDateTimeFailureNotification": {
|
||||
"description": "Failed to set the date & time of a file"
|
||||
},
|
||||
"changelogTitle": "Changelog",
|
||||
"@changelogTitle": {
|
||||
"description": "Title of the changelog dialog"
|
||||
|
|
|
@ -14,8 +14,9 @@ class UpdateProperty {
|
|||
File file, {
|
||||
OrNull<Metadata> metadata,
|
||||
OrNull<bool> isArchived,
|
||||
OrNull<DateTime> overrideDateTime,
|
||||
}) async {
|
||||
if (metadata == null && isArchived == null) {
|
||||
if (metadata == null && isArchived == null && overrideDateTime == null) {
|
||||
// ?
|
||||
_log.warning("[call] Nothing to update");
|
||||
return;
|
||||
|
@ -30,6 +31,7 @@ class UpdateProperty {
|
|||
file,
|
||||
metadata: metadata,
|
||||
isArchived: isArchived,
|
||||
overrideDateTime: overrideDateTime,
|
||||
);
|
||||
|
||||
int properties = 0;
|
||||
|
@ -39,6 +41,9 @@ class UpdateProperty {
|
|||
if (isArchived != null) {
|
||||
properties |= FilePropertyUpdatedEvent.propIsArchived;
|
||||
}
|
||||
if (overrideDateTime != null) {
|
||||
properties |= FilePropertyUpdatedEvent.propOverrideDateTime;
|
||||
}
|
||||
assert(properties != 0);
|
||||
KiwiContainer()
|
||||
.resolve<EventBus>()
|
||||
|
@ -62,4 +67,11 @@ extension UpdatePropertyExtension on UpdateProperty {
|
|||
/// See [UpdateProperty.call]
|
||||
Future<void> updateIsArchived(Account account, File file, bool isArchived) =>
|
||||
call(account, file, isArchived: OrNull(isArchived));
|
||||
|
||||
/// Convenience function to only update overrideDateTime
|
||||
///
|
||||
/// See [UpdateProperty.call]
|
||||
Future<void> updateOverrideDateTime(
|
||||
Account account, File file, DateTime overrideDateTime) =>
|
||||
call(account, file, overrideDateTime: OrNull(overrideDateTime));
|
||||
}
|
||||
|
|
205
lib/widget/photo_date_time_edit_dialog.dart
Normal file
205
lib/widget/photo_date_time_edit_dialog.dart
Normal file
|
@ -0,0 +1,205 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/num_extension.dart';
|
||||
|
||||
class PhotoDateTimeEditDialog extends StatefulWidget {
|
||||
PhotoDateTimeEditDialog({
|
||||
Key key,
|
||||
@required this.initialDateTime,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
createState() => _PhotoDateTimeEditDialogState();
|
||||
|
||||
final DateTime initialDateTime;
|
||||
}
|
||||
|
||||
class _PhotoDateTimeEditDialogState extends State<PhotoDateTimeEditDialog> {
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context).updateDateTimeDialogTitle),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Container(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).dateSubtitle,
|
||||
style: Theme.of(context).textTheme.subtitle2,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
AppLocalizations.of(context).dateYearInputHint,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
try {
|
||||
int.parse(value);
|
||||
return null;
|
||||
} catch (_) {
|
||||
return AppLocalizations.of(context)
|
||||
.dateTimeInputInvalid;
|
||||
}
|
||||
},
|
||||
onSaved: (value) {
|
||||
_formValue.year = int.parse(value);
|
||||
},
|
||||
initialValue: "${widget.initialDateTime.year}",
|
||||
),
|
||||
flex: 1,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
AppLocalizations.of(context).dateMonthInputHint,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (int.tryParse(value)?.inRange(1, 12) == true) {
|
||||
return null;
|
||||
}
|
||||
return AppLocalizations.of(context)
|
||||
.dateTimeInputInvalid;
|
||||
},
|
||||
onSaved: (value) {
|
||||
_formValue.month = int.parse(value);
|
||||
},
|
||||
initialValue: widget.initialDateTime.month
|
||||
.toString()
|
||||
.padLeft(2, "0"),
|
||||
),
|
||||
flex: 1,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(context).dateDayInputHint,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (int.tryParse(value)?.inRange(1, 31) == true) {
|
||||
return null;
|
||||
}
|
||||
return AppLocalizations.of(context)
|
||||
.dateTimeInputInvalid;
|
||||
},
|
||||
onSaved: (value) {
|
||||
_formValue.day = int.parse(value);
|
||||
},
|
||||
initialValue:
|
||||
widget.initialDateTime.day.toString().padLeft(2, "0"),
|
||||
),
|
||||
flex: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(context).timeSubtitle,
|
||||
style: Theme.of(context).textTheme.subtitle2,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
AppLocalizations.of(context).timeHourInputHint,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (int.tryParse(value)?.inRange(0, 23) == true) {
|
||||
return null;
|
||||
}
|
||||
return AppLocalizations.of(context)
|
||||
.dateTimeInputInvalid;
|
||||
},
|
||||
onSaved: (value) {
|
||||
_formValue.hour = int.parse(value);
|
||||
},
|
||||
initialValue: widget.initialDateTime.hour
|
||||
.toString()
|
||||
.padLeft(2, "0"),
|
||||
),
|
||||
flex: 1,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
AppLocalizations.of(context).timeMinuteInputHint,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (int.tryParse(value)?.inRange(0, 59) == true) {
|
||||
return null;
|
||||
}
|
||||
return AppLocalizations.of(context)
|
||||
.dateTimeInputInvalid;
|
||||
},
|
||||
onSaved: (value) {
|
||||
_formValue.minute = int.parse(value);
|
||||
},
|
||||
initialValue: widget.initialDateTime.minute
|
||||
.toString()
|
||||
.padLeft(2, "0"),
|
||||
),
|
||||
flex: 1,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: const SizedBox(),
|
||||
flex: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => _onSavePressed(context),
|
||||
child: Text(MaterialLocalizations.of(context).saveButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onSavePressed(BuildContext context) {
|
||||
if (_formKey.currentState.validate()) {
|
||||
_formKey.currentState.save();
|
||||
final d = DateTime(_formValue.year, _formValue.month, _formValue.day,
|
||||
_formValue.hour, _formValue.minute);
|
||||
_log.info("[_onSavePressed] Set date time: $d");
|
||||
Navigator.of(context).pop(d);
|
||||
}
|
||||
}
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _formValue = _FormValue();
|
||||
|
||||
static final _log = Logger(
|
||||
"widget.photo_date_time_edit_dialog._PhotoDateTimeEditDialogState");
|
||||
}
|
||||
|
||||
class _FormValue {
|
||||
int year;
|
||||
int month;
|
||||
int day;
|
||||
int hour;
|
||||
int minute;
|
||||
}
|
|
@ -22,7 +22,9 @@ import 'package:nc_photos/snack_bar_manager.dart';
|
|||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/use_case/remove.dart';
|
||||
import 'package:nc_photos/use_case/update_album.dart';
|
||||
import 'package:nc_photos/use_case/update_property.dart';
|
||||
import 'package:nc_photos/widget/album_picker_dialog.dart';
|
||||
import 'package:nc_photos/widget/photo_date_time_edit_dialog.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -45,6 +47,7 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
|
|||
initState() {
|
||||
super.initState();
|
||||
|
||||
_dateTime = widget.file.bestDateTime.toLocal();
|
||||
if (widget.file.metadata == null) {
|
||||
_log.info("[initState] Metadata missing in File");
|
||||
} else {
|
||||
|
@ -57,9 +60,9 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
|
|||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
final dateTime = widget.file.bestDateTime.toLocal();
|
||||
final dateStr = DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY).format(dateTime);
|
||||
final timeStr = DateFormat(DateFormat.HOUR_MINUTE).format(dateTime);
|
||||
final dateStr =
|
||||
DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY).format(_dateTime);
|
||||
final timeStr = DateFormat(DateFormat.HOUR_MINUTE).format(_dateTime);
|
||||
|
||||
String sizeSubStr = "";
|
||||
const space = " ";
|
||||
|
@ -95,80 +98,88 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
|
|||
}
|
||||
cameraSubStr = cameraSubStr.trim();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_DetailPaneButton(
|
||||
icon: Icons.playlist_add_outlined,
|
||||
label: AppLocalizations.of(context).addToAlbumTooltip,
|
||||
onPressed: () => _onAddToAlbumPressed(context),
|
||||
),
|
||||
_DetailPaneButton(
|
||||
icon: Icons.delete_outline,
|
||||
label: AppLocalizations.of(context).deleteTooltip,
|
||||
onPressed: () => _onDeletePressed(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: const Divider(),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.image_outlined,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_DetailPaneButton(
|
||||
icon: Icons.playlist_add_outlined,
|
||||
label: AppLocalizations.of(context).addToAlbumTooltip,
|
||||
onPressed: () => _onAddToAlbumPressed(context),
|
||||
),
|
||||
_DetailPaneButton(
|
||||
icon: Icons.delete_outline,
|
||||
label: AppLocalizations.of(context).deleteTooltip,
|
||||
onPressed: () => _onDeletePressed(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(basenameWithoutExtension(widget.file.path)),
|
||||
subtitle: Text(widget.file.strippedPath),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: const Divider(),
|
||||
),
|
||||
title: Text("$dateStr $timeStr"),
|
||||
),
|
||||
if (widget.file.metadata?.imageWidth != null &&
|
||||
widget.file.metadata?.imageHeight != null)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.aspect_ratio,
|
||||
Icons.image_outlined,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
title: Text(
|
||||
"${widget.file.metadata.imageWidth} x ${widget.file.metadata.imageHeight}"),
|
||||
subtitle: Text(sizeSubStr),
|
||||
)
|
||||
else
|
||||
title: Text(basenameWithoutExtension(widget.file.path)),
|
||||
subtitle: Text(widget.file.strippedPath),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.aspect_ratio,
|
||||
Icons.calendar_today_outlined,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
title: Text(_byteSizeToString(widget.file.contentLength)),
|
||||
),
|
||||
if (_model != null)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.camera_outlined,
|
||||
title: Text("$dateStr $timeStr"),
|
||||
trailing: Icon(
|
||||
Icons.edit_outlined,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
title: Text(_model),
|
||||
subtitle: cameraSubStr.isNotEmpty ? Text(cameraSubStr) : null,
|
||||
onTap: () => _onDateTimeTap(context),
|
||||
),
|
||||
if (features.isSupportMapView && _gps != null)
|
||||
SizedBox(
|
||||
height: 256,
|
||||
child: platform.Map(
|
||||
center: _gps,
|
||||
zoom: 16,
|
||||
onTap: _onMapTap,
|
||||
if (widget.file.metadata?.imageWidth != null &&
|
||||
widget.file.metadata?.imageHeight != null)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.aspect_ratio,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
title: Text(
|
||||
"${widget.file.metadata.imageWidth} x ${widget.file.metadata.imageHeight}"),
|
||||
subtitle: Text(sizeSubStr),
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.aspect_ratio,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
title: Text(_byteSizeToString(widget.file.contentLength)),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_model != null)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.camera_outlined,
|
||||
color: AppTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
title: Text(_model),
|
||||
subtitle: cameraSubStr.isNotEmpty ? Text(cameraSubStr) : null,
|
||||
),
|
||||
if (features.isSupportMapView && _gps != null)
|
||||
SizedBox(
|
||||
height: 256,
|
||||
child: platform.Map(
|
||||
center: _gps,
|
||||
zoom: 16,
|
||||
onTap: _onMapTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -290,6 +301,38 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
setState(() {
|
||||
_dateTime = value;
|
||||
});
|
||||
} catch (e, stacktrace) {
|
||||
_log.shout(
|
||||
"[_onDateTimeTap] Failed while updateOverrideDateTime" +
|
||||
(kDebugMode ? ": ${widget.file.path}" : ""),
|
||||
e,
|
||||
stacktrace);
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).updateDateTimeFailureNotification),
|
||||
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) {
|
||||
|
@ -334,6 +377,7 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
|
|||
}
|
||||
}
|
||||
|
||||
DateTime _dateTime;
|
||||
// EXIF data
|
||||
String _model;
|
||||
double _fNumber;
|
||||
|
|
|
@ -461,6 +461,18 @@ void main() {
|
|||
final file = File.fromJson(json);
|
||||
expect(file, File(path: "", isArchived: true));
|
||||
});
|
||||
|
||||
test("overrideDateTime", () {
|
||||
final json = <String, dynamic>{
|
||||
"path": "",
|
||||
"overrideDateTime": "2021-01-02T03:04:05.000Z",
|
||||
};
|
||||
final file = File.fromJson(json);
|
||||
expect(
|
||||
file,
|
||||
File(
|
||||
path: "", overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5)));
|
||||
});
|
||||
});
|
||||
|
||||
group("toJson", () {
|
||||
|
@ -578,6 +590,16 @@ void main() {
|
|||
"isArchived": true,
|
||||
});
|
||||
});
|
||||
|
||||
test("overrideDateTime", () {
|
||||
final file = File(
|
||||
path: "remote.php/dav/files/admin/test.jpg",
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5));
|
||||
expect(file.toJson(), <String, dynamic>{
|
||||
"path": "remote.php/dav/files/admin/test.jpg",
|
||||
"overrideDateTime": "2021-01-02T03:04:05.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group("copyWith", () {
|
||||
|
@ -594,6 +616,7 @@ void main() {
|
|||
ownerId: "admin",
|
||||
metadata: null,
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
);
|
||||
|
||||
test("path", () {
|
||||
|
@ -612,6 +635,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -631,6 +655,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -650,6 +675,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -669,6 +695,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -689,6 +716,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -708,6 +736,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -727,6 +756,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -746,6 +776,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -765,6 +796,7 @@ void main() {
|
|||
fileId: 321,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -784,6 +816,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "user",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -805,6 +838,7 @@ void main() {
|
|||
ownerId: "admin",
|
||||
metadata: metadata,
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -822,6 +856,7 @@ void main() {
|
|||
ownerId: "admin",
|
||||
metadata: Metadata(),
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
);
|
||||
final file = src.copyWith(metadata: OrNull(null));
|
||||
expect(
|
||||
|
@ -838,6 +873,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -857,6 +893,7 @@ void main() {
|
|||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: false,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
|
@ -875,6 +912,47 @@ void main() {
|
|||
hasPreview: true,
|
||||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
));
|
||||
});
|
||||
|
||||
test("overrideDateTime", () {
|
||||
final file = src.copyWith(
|
||||
overrideDateTime: OrNull(DateTime.utc(2022, 3, 4, 5, 6, 7)));
|
||||
expect(
|
||||
file,
|
||||
File(
|
||||
path: "remote.php/dav/files/admin/test.jpg",
|
||||
contentLength: 123,
|
||||
contentType: "image/jpeg",
|
||||
etag: "8a3e0799b6f0711c23cc2d93950eceb5",
|
||||
lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901),
|
||||
isCollection: true,
|
||||
usedBytes: 123456,
|
||||
hasPreview: true,
|
||||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
overrideDateTime: DateTime.utc(2022, 3, 4, 5, 6, 7),
|
||||
));
|
||||
});
|
||||
|
||||
test("clear overrideDateTime", () {
|
||||
final file = src.copyWith(overrideDateTime: OrNull(null));
|
||||
expect(
|
||||
file,
|
||||
File(
|
||||
path: "remote.php/dav/files/admin/test.jpg",
|
||||
contentLength: 123,
|
||||
contentType: "image/jpeg",
|
||||
etag: "8a3e0799b6f0711c23cc2d93950eceb5",
|
||||
lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901),
|
||||
isCollection: true,
|
||||
usedBytes: 123456,
|
||||
hasPreview: true,
|
||||
fileId: 123,
|
||||
ownerId: "admin",
|
||||
isArchived: true,
|
||||
));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -175,6 +175,45 @@ void main() {
|
|||
]);
|
||||
});
|
||||
|
||||
test("file w/ override-date-time", () {
|
||||
final xml = XmlDocument.parse("""
|
||||
<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:"
|
||||
xmlns:s="http://sabredav.org/ns"
|
||||
xmlns:oc="http://owncloud.org/ns"
|
||||
xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/admin/Photos/Nextcloud%20community.jpg</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getlastmodified>Fri, 01 Jan 2021 02:03:04 GMT</d:getlastmodified>
|
||||
<d:getetag>"8950e39a034e369237d9285e2d815a50"</d:getetag>
|
||||
<d:getcontenttype>image/jpeg</d:getcontenttype>
|
||||
<d:resourcetype/>
|
||||
<d:getcontentlength>797325</d:getcontentlength>
|
||||
<nc:has-preview>true</nc:has-preview>
|
||||
<x1:override-date-time xmlns:x1="com.nkming.nc_photos">2021-01-02T03:04:05.000Z</x1:override-date-time>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
""");
|
||||
final results = WebdavFileParser()(xml);
|
||||
expect(results, [
|
||||
File(
|
||||
path: "remote.php/dav/files/admin/Photos/Nextcloud community.jpg",
|
||||
contentLength: 797325,
|
||||
contentType: "image/jpeg",
|
||||
etag: "8950e39a034e369237d9285e2d815a50",
|
||||
lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4),
|
||||
hasPreview: true,
|
||||
isCollection: false,
|
||||
overrideDateTime: DateTime.utc(2021, 1, 2, 3, 4, 5),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test("multiple files", () {
|
||||
final xml = XmlDocument.parse("""
|
||||
<?xml version="1.0"?>
|
||||
|
|
Loading…
Reference in a new issue