mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-03-13 18:58:53 +01:00
Add memory album
This commit is contained in:
parent
0993324488
commit
042a927a2f
19 changed files with 1445 additions and 38 deletions
100
lib/app_db.dart
100
lib/app_db.dart
|
@ -10,16 +10,18 @@ import 'package:nc_photos/entity/album/upgrader.dart';
|
|||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/mobile/platform.dart'
|
||||
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
|
||||
import 'package:nc_photos/num_extension.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/type.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class AppDb {
|
||||
static const dbName = "app.db";
|
||||
static const dbVersion = 4;
|
||||
static const dbVersion = 5;
|
||||
static const albumStoreName = "albums";
|
||||
static const file2StoreName = "files2";
|
||||
static const dirStoreName = "dirs";
|
||||
static const metaStoreName = "meta";
|
||||
|
||||
factory AppDb() => _inst;
|
||||
|
||||
|
@ -45,13 +47,14 @@ class AppDb {
|
|||
/// Open the database
|
||||
Future<Database> _open() async {
|
||||
final dbFactory = platform.getDbFactory();
|
||||
return dbFactory.open(dbName, version: dbVersion,
|
||||
onUpgradeNeeded: (event) async {
|
||||
int? fromVersion, toVersion;
|
||||
final db = await dbFactory.open(dbName, version: dbVersion,
|
||||
onUpgradeNeeded: (event) {
|
||||
_log.info("[_open] Upgrade database: ${event.oldVersion} -> $dbVersion");
|
||||
|
||||
final db = event.database;
|
||||
// ignore: unused_local_variable
|
||||
ObjectStore? albumStore, file2Store, dirStore;
|
||||
ObjectStore? albumStore, file2Store, dirStore, metaStore;
|
||||
if (event.oldVersion < 2) {
|
||||
// version 2 store things in a new way, just drop all
|
||||
try {
|
||||
|
@ -82,7 +85,32 @@ class AppDb {
|
|||
|
||||
dirStore = db.createObjectStore(dirStoreName);
|
||||
}
|
||||
file2Store ??= event.transaction.objectStore(file2StoreName);
|
||||
if (event.oldVersion < 5) {
|
||||
file2Store.createIndex(AppDbFile2Entry.dateTimeEpochMsIndexName,
|
||||
AppDbFile2Entry.dateTimeEpochMsKeyPath);
|
||||
|
||||
metaStore = db.createObjectStore(metaStoreName,
|
||||
keyPath: AppDbMetaEntry.keyPath);
|
||||
}
|
||||
fromVersion = event.oldVersion;
|
||||
toVersion = event.newVersion;
|
||||
});
|
||||
if (fromVersion != null && toVersion != null) {
|
||||
await _onPostUpgrade(db, fromVersion!, toVersion!);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
Future<void> _onPostUpgrade(
|
||||
Database db, int fromVersion, int toVersion) async {
|
||||
if (fromVersion.inRange(1, 4) && toVersion >= 5) {
|
||||
final transaction = db.transaction(AppDb.metaStoreName, idbModeReadWrite);
|
||||
final metaStore = transaction.objectStore(AppDb.metaStoreName);
|
||||
await metaStore
|
||||
.put(const AppDbMetaEntryDbCompatV5(false).toEntry().toJson());
|
||||
await transaction.completed;
|
||||
}
|
||||
}
|
||||
|
||||
static late final _inst = AppDb._();
|
||||
|
@ -140,16 +168,21 @@ class AppDbFile2Entry with EquatableMixin {
|
|||
static const strippedPathIndexName = "server_userId_strippedPath";
|
||||
static const strippedPathKeyPath = ["server", "userId", "strippedPath"];
|
||||
|
||||
AppDbFile2Entry._(this.server, this.userId, this.strippedPath, this.file);
|
||||
static const dateTimeEpochMsIndexName = "server_userId_dateTimeEpochMs";
|
||||
static const dateTimeEpochMsKeyPath = ["server", "userId", "dateTimeEpochMs"];
|
||||
|
||||
AppDbFile2Entry(this.server, this.userId, this.strippedPath,
|
||||
this.dateTimeEpochMs, this.file);
|
||||
|
||||
factory AppDbFile2Entry.fromFile(Account account, File file) =>
|
||||
AppDbFile2Entry._(
|
||||
account.url, account.username, file.strippedPathWithEmpty, file);
|
||||
AppDbFile2Entry(account.url, account.username, file.strippedPathWithEmpty,
|
||||
file.bestDateTime.millisecondsSinceEpoch, file);
|
||||
|
||||
factory AppDbFile2Entry.fromJson(JsonObj json) => AppDbFile2Entry._(
|
||||
factory AppDbFile2Entry.fromJson(JsonObj json) => AppDbFile2Entry(
|
||||
json["server"],
|
||||
(json["userId"] as String).toCi(),
|
||||
json["strippedPath"],
|
||||
json["dateTimeEpochMs"],
|
||||
File.fromJson(json["file"].cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
|
@ -157,6 +190,7 @@ class AppDbFile2Entry with EquatableMixin {
|
|||
"server": server,
|
||||
"userId": userId.toCaseInsensitiveString(),
|
||||
"strippedPath": strippedPath,
|
||||
"dateTimeEpochMs": dateTimeEpochMs,
|
||||
"file": file.toJson(),
|
||||
};
|
||||
|
||||
|
@ -198,11 +232,19 @@ class AppDbFile2Entry with EquatableMixin {
|
|||
});
|
||||
}
|
||||
|
||||
static List<Object> toDateTimeEpochMsIndexKey(Account account, int epochMs) =>
|
||||
[
|
||||
account.url,
|
||||
account.username.toCaseInsensitiveString(),
|
||||
epochMs,
|
||||
];
|
||||
|
||||
@override
|
||||
get props => [
|
||||
server,
|
||||
userId,
|
||||
strippedPath,
|
||||
dateTimeEpochMs,
|
||||
file,
|
||||
];
|
||||
|
||||
|
@ -210,6 +252,7 @@ class AppDbFile2Entry with EquatableMixin {
|
|||
final String server;
|
||||
final CiString userId;
|
||||
final String strippedPath;
|
||||
final int dateTimeEpochMs;
|
||||
final File file;
|
||||
}
|
||||
|
||||
|
@ -274,3 +317,44 @@ class AppDbDirEntry with EquatableMixin {
|
|||
final File dir;
|
||||
final List<int> children;
|
||||
}
|
||||
|
||||
|
||||
class AppDbMetaEntry with EquatableMixin {
|
||||
static const keyPath = "key";
|
||||
|
||||
const AppDbMetaEntry(this.key, this.obj);
|
||||
|
||||
factory AppDbMetaEntry.fromJson(JsonObj json) => AppDbMetaEntry(
|
||||
json["key"],
|
||||
json["obj"].cast<String, dynamic>(),
|
||||
);
|
||||
|
||||
JsonObj toJson() => {
|
||||
"key": key,
|
||||
"obj": obj,
|
||||
};
|
||||
|
||||
@override
|
||||
get props => [
|
||||
key,
|
||||
obj,
|
||||
];
|
||||
|
||||
final String key;
|
||||
final JsonObj obj;
|
||||
}
|
||||
|
||||
class AppDbMetaEntryDbCompatV5 {
|
||||
static const key = "dbCompatV5";
|
||||
|
||||
const AppDbMetaEntryDbCompatV5(this.isMigrated);
|
||||
|
||||
factory AppDbMetaEntryDbCompatV5.fromJson(JsonObj json) =>
|
||||
AppDbMetaEntryDbCompatV5(json["isMigrated"]);
|
||||
|
||||
AppDbMetaEntry toEntry() => AppDbMetaEntry(key, {
|
||||
"isMigrated": isMigrated,
|
||||
});
|
||||
|
||||
final bool isMigrated;
|
||||
}
|
||||
|
|
40
lib/date_time_extension.dart
Normal file
40
lib/date_time_extension.dart
Normal file
|
@ -0,0 +1,40 @@
|
|||
extension DateTimeExtension on DateTime {
|
||||
DateTime copyWith({
|
||||
int? year,
|
||||
int? month,
|
||||
int? day,
|
||||
int? hour,
|
||||
int? minute,
|
||||
int? second,
|
||||
int? millisecond,
|
||||
int? microsecond,
|
||||
}) {
|
||||
if (isUtc) {
|
||||
return DateTime.utc(
|
||||
year ?? this.year,
|
||||
month ?? this.month,
|
||||
day ?? this.day,
|
||||
hour ?? this.hour,
|
||||
minute ?? this.minute,
|
||||
second ?? this.second,
|
||||
millisecond ?? this.millisecond,
|
||||
microsecond ?? this.microsecond,
|
||||
);
|
||||
} else {
|
||||
return DateTime(
|
||||
year ?? this.year,
|
||||
month ?? this.month,
|
||||
day ?? this.day,
|
||||
hour ?? this.hour,
|
||||
minute ?? this.minute,
|
||||
second ?? this.second,
|
||||
millisecond ?? this.millisecond,
|
||||
microsecond ?? this.microsecond,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a new object representing the midnight of the same day
|
||||
DateTime toMidnight() =>
|
||||
copyWith(hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0);
|
||||
}
|
|
@ -220,3 +220,55 @@ class AlbumDirProvider extends AlbumDynamicProvider {
|
|||
|
||||
static const _type = "dir";
|
||||
}
|
||||
|
||||
/// Smart albums are created only by the app and not the user
|
||||
abstract class AlbumSmartProvider extends AlbumProviderBase {
|
||||
AlbumSmartProvider({
|
||||
DateTime? latestItemTime,
|
||||
}) : super(latestItemTime: latestItemTime);
|
||||
|
||||
@override
|
||||
AlbumDirProvider copyWith({
|
||||
OrNull<DateTime>? latestItemTime,
|
||||
}) {
|
||||
// Smart albums do not support copying
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
toContentJson() {
|
||||
// Smart albums do not support saving
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory album is created based on dates
|
||||
class AlbumMemoryProvider extends AlbumSmartProvider {
|
||||
AlbumMemoryProvider({
|
||||
required this.year,
|
||||
required this.month,
|
||||
required this.day,
|
||||
}) : super(latestItemTime: DateTime(year, month, day));
|
||||
|
||||
@override
|
||||
toString({bool isDeep = false}) {
|
||||
return "$runtimeType {"
|
||||
"super: ${super.toString(isDeep: isDeep)}, "
|
||||
"year: $year, "
|
||||
"month: $month, "
|
||||
"day: $day, "
|
||||
"}";
|
||||
}
|
||||
|
||||
@override
|
||||
get props => [
|
||||
...super.props,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
];
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final int day;
|
||||
}
|
||||
|
|
|
@ -281,6 +281,32 @@ class FileAppDbDataSource implements FileDataSource {
|
|||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// List files with date between [fromEpochMs] (inclusive) and [toEpochMs]
|
||||
/// (exclusive)
|
||||
Future<List<File>> listByDate(
|
||||
Account account, int fromEpochMs, int toEpochMs) async {
|
||||
_log.info("[listByDate] [$fromEpochMs, $toEpochMs]");
|
||||
final items = await appDb.use((db) async {
|
||||
final transaction = db.transaction(AppDb.file2StoreName, idbModeReadOnly);
|
||||
final fileStore = transaction.objectStore(AppDb.file2StoreName);
|
||||
final dateTimeEpochMsIndex =
|
||||
fileStore.index(AppDbFile2Entry.dateTimeEpochMsIndexName);
|
||||
final range = KeyRange.bound(
|
||||
AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, fromEpochMs),
|
||||
AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, toEpochMs),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
return await dateTimeEpochMsIndex.getAll(range);
|
||||
});
|
||||
return items
|
||||
.cast<Map>()
|
||||
.map((i) => AppDbFile2Entry.fromJson(i.cast<String, dynamic>()))
|
||||
.map((e) => e.file)
|
||||
.where((f) => _validateFile(f))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Remove a file/dir from database
|
||||
///
|
||||
/// If [f] is a dir, the dir and its sub-dirs will be removed from dirStore.
|
||||
|
|
|
@ -1088,6 +1088,20 @@
|
|||
"description": "This dialog is shown when user first open a shared album"
|
||||
},
|
||||
"learnMoreButtonLabel": "LEARN MORE",
|
||||
"migrateDatabaseProcessingNotification": "Updating database",
|
||||
"@migrateDatabaseProcessingNotification": {
|
||||
"description": "Migrate database to work with the updated app"
|
||||
},
|
||||
"migrateDatabaseFailureNotification": "Failed migrating database",
|
||||
"memoryAlbumName": "{count, plural, =1{1 year ago} other{{count} years ago}}",
|
||||
"@memoryAlbumName": {
|
||||
"description": "Memory albums are generated by the app and include photos in the past years",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
||||
"@errorUnauthenticated": {
|
||||
|
|
|
@ -53,6 +53,9 @@
|
|||
"sharedAlbumInfoDialogTitle",
|
||||
"sharedAlbumInfoDialogContent",
|
||||
"learnMoreButtonLabel",
|
||||
"migrateDatabaseProcessingNotification",
|
||||
"migrateDatabaseFailureNotification",
|
||||
"memoryAlbumName",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -124,6 +127,9 @@
|
|||
"sharedAlbumInfoDialogTitle",
|
||||
"sharedAlbumInfoDialogContent",
|
||||
"learnMoreButtonLabel",
|
||||
"migrateDatabaseProcessingNotification",
|
||||
"migrateDatabaseFailureNotification",
|
||||
"memoryAlbumName",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -250,6 +256,9 @@
|
|||
"sharedAlbumInfoDialogTitle",
|
||||
"sharedAlbumInfoDialogContent",
|
||||
"learnMoreButtonLabel",
|
||||
"migrateDatabaseProcessingNotification",
|
||||
"migrateDatabaseFailureNotification",
|
||||
"memoryAlbumName",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -259,6 +268,9 @@
|
|||
"sharedAlbumInfoDialogTitle",
|
||||
"sharedAlbumInfoDialogContent",
|
||||
"learnMoreButtonLabel",
|
||||
"migrateDatabaseProcessingNotification",
|
||||
"migrateDatabaseFailureNotification",
|
||||
"memoryAlbumName",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -268,6 +280,9 @@
|
|||
"sharedAlbumInfoDialogTitle",
|
||||
"sharedAlbumInfoDialogContent",
|
||||
"learnMoreButtonLabel",
|
||||
"migrateDatabaseProcessingNotification",
|
||||
"migrateDatabaseFailureNotification",
|
||||
"memoryAlbumName",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -374,6 +389,9 @@
|
|||
"sharedAlbumInfoDialogTitle",
|
||||
"sharedAlbumInfoDialogContent",
|
||||
"learnMoreButtonLabel",
|
||||
"migrateDatabaseProcessingNotification",
|
||||
"migrateDatabaseFailureNotification",
|
||||
"memoryAlbumName",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -453,6 +471,9 @@
|
|||
"sharedAlbumInfoDialogTitle",
|
||||
"sharedAlbumInfoDialogContent",
|
||||
"learnMoreButtonLabel",
|
||||
"migrateDatabaseProcessingNotification",
|
||||
"migrateDatabaseFailureNotification",
|
||||
"memoryAlbumName",
|
||||
"errorAlbumDowngrade"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -55,8 +55,8 @@ class AppTheme extends StatelessWidget {
|
|||
|
||||
static Color getPrimaryTextColor(BuildContext context) {
|
||||
return Theme.of(context).brightness == Brightness.light
|
||||
? Colors.black87
|
||||
: Colors.white.withOpacity(.87);
|
||||
? primaryTextColorLight
|
||||
: primaryTextColorDark;
|
||||
}
|
||||
|
||||
static Color getSecondaryTextColor(BuildContext context) {
|
||||
|
@ -132,6 +132,9 @@ class AppTheme extends StatelessWidget {
|
|||
static const primarySwatchLight = Colors.blue;
|
||||
static const primarySwatchDark = Colors.cyan;
|
||||
|
||||
static const primaryTextColorLight = Colors.black87;
|
||||
static final primaryTextColorDark = Colors.white.withOpacity(.87);
|
||||
|
||||
static const widthLimitedContentMaxWidth = 550.0;
|
||||
|
||||
/// Make a TextButton look like a default FlatButton. See
|
||||
|
|
63
lib/use_case/db_compat/v5.dart
Normal file
63
lib/use_case/db_compat/v5.dart
Normal file
|
@ -0,0 +1,63 @@
|
|||
import 'package:idb_shim/idb_client.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/app_db.dart';
|
||||
import 'package:nc_photos/ci_string.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
|
||||
class DbCompatV5 {
|
||||
static Future<bool> isNeedMigration(AppDb appDb) async {
|
||||
final dbItem = await appDb.use((db) async {
|
||||
final transaction = db.transaction(AppDb.metaStoreName, idbModeReadOnly);
|
||||
final metaStore = transaction.objectStore(AppDb.metaStoreName);
|
||||
return await metaStore.getObject(AppDbMetaEntryDbCompatV5.key) as Map?;
|
||||
});
|
||||
if (dbItem == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final dbEntry = AppDbMetaEntry.fromJson(dbItem.cast<String, dynamic>());
|
||||
final compatV35 = AppDbMetaEntryDbCompatV5.fromJson(dbEntry.obj);
|
||||
return !compatV35.isMigrated;
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[isNeedMigration] Failed", e, stackTrace);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> migrate(AppDb appDb) async {
|
||||
_log.info("[migrate] Migrate AppDb");
|
||||
await appDb.use((db) async {
|
||||
final transaction = db.transaction(
|
||||
[AppDb.file2StoreName, AppDb.metaStoreName], idbModeReadWrite);
|
||||
try {
|
||||
final fileStore = transaction.objectStore(AppDb.file2StoreName);
|
||||
await for (final c in fileStore.openCursor()) {
|
||||
final item = c.value as Map;
|
||||
// migrate file entry: add bestDateTime
|
||||
final fileEntry = item.cast<String, dynamic>().run((json) {
|
||||
final f = File.fromJson(json["file"].cast<String, dynamic>());
|
||||
return AppDbFile2Entry(
|
||||
json["server"],
|
||||
(json["userId"] as String).toCi(),
|
||||
json["strippedPath"],
|
||||
f.bestDateTime.millisecondsSinceEpoch,
|
||||
File.fromJson(json["file"].cast<String, dynamic>()),
|
||||
);
|
||||
});
|
||||
await c.update(fileEntry.toJson());
|
||||
|
||||
c.next();
|
||||
}
|
||||
final metaStore = transaction.objectStore(AppDb.metaStoreName);
|
||||
await metaStore
|
||||
.put(const AppDbMetaEntryDbCompatV5(true).toEntry().toJson());
|
||||
} catch (_) {
|
||||
transaction.abort();
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static final _log = Logger("use_case.db_compat.v5.DbCompatV5");
|
||||
}
|
|
@ -7,6 +7,7 @@ import 'package:nc_photos/entity/album/item.dart';
|
|||
import 'package:nc_photos/entity/album/provider.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/data_source.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/exception_event.dart';
|
||||
import 'package:nc_photos/use_case/scan_dir.dart';
|
||||
|
||||
|
@ -18,9 +19,10 @@ class PopulateAlbum {
|
|||
_log.warning(
|
||||
"[call] Populate only make sense for dynamic albums: ${album.name}");
|
||||
return AlbumStaticProvider.of(album).items;
|
||||
}
|
||||
if (album.provider is AlbumDirProvider) {
|
||||
} else if (album.provider is AlbumDirProvider) {
|
||||
return _populateDirAlbum(account, album);
|
||||
} else if (album.provider is AlbumMemoryProvider) {
|
||||
return _populateMemoryAlbum(account, album);
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
"Unknown album provider: ${album.provider.runtimeType}");
|
||||
|
@ -52,6 +54,25 @@ class PopulateAlbum {
|
|||
return products;
|
||||
}
|
||||
|
||||
Future<List<AlbumItem>> _populateMemoryAlbum(
|
||||
Account account, Album album) async {
|
||||
assert(album.provider is AlbumMemoryProvider);
|
||||
final provider = album.provider as AlbumMemoryProvider;
|
||||
final date = DateTime(provider.year, provider.month, provider.day);
|
||||
final from = date.subtract(const Duration(days: 3));
|
||||
final to = date.add(const Duration(days: 4));
|
||||
final files = await FileAppDbDataSource(appDb).listByDate(account,
|
||||
from.millisecondsSinceEpoch, to.millisecondsSinceEpoch);
|
||||
return files
|
||||
.where((f) => file_util.isSupportedFormat(f))
|
||||
.map((f) => AlbumFileItem(
|
||||
addedBy: account.username,
|
||||
addedAt: DateTime.now(),
|
||||
file: f,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final AppDb appDb;
|
||||
|
||||
static final _log = Logger("use_case.populate_album.PopulateAlbum");
|
||||
|
|
|
@ -17,7 +17,8 @@ class PreProcessAlbum {
|
|||
Future<List<AlbumItem>> call(Account account, Album album) {
|
||||
if (album.provider is AlbumStaticProvider) {
|
||||
return ResyncAlbum(appDb)(account, album);
|
||||
} else if (album.provider is AlbumDynamicProvider) {
|
||||
} else if (album.provider is AlbumDynamicProvider ||
|
||||
album.provider is AlbumSmartProvider) {
|
||||
return PopulateAlbum(appDb)(account, album);
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
|
|
|
@ -4,12 +4,16 @@ import 'package:nc_photos/entity/album.dart';
|
|||
import 'package:nc_photos/entity/album/provider.dart';
|
||||
import 'package:nc_photos/widget/album_browser.dart';
|
||||
import 'package:nc_photos/widget/dynamic_album_browser.dart';
|
||||
import 'package:nc_photos/widget/smart_album_browser.dart';
|
||||
|
||||
/// Push the corresponding browser route for this album
|
||||
Future<void> push(BuildContext context, Account account, Album album) {
|
||||
if (album.provider is AlbumStaticProvider) {
|
||||
return Navigator.of(context).pushNamed(AlbumBrowser.routeName,
|
||||
arguments: AlbumBrowserArguments(account, album));
|
||||
} else if (album.provider is AlbumSmartProvider) {
|
||||
return Navigator.of(context).pushNamed(SmartAlbumBrowser.routeName,
|
||||
arguments: SmartAlbumBrowserArguments(account, album));
|
||||
} else {
|
||||
return Navigator.of(context).pushNamed(DynamicAlbumBrowser.routeName,
|
||||
arguments: DynamicAlbumBrowserArguments(account, album));
|
||||
|
@ -23,6 +27,10 @@ Future<void> pushReplacement(
|
|||
if (album.provider is AlbumStaticProvider) {
|
||||
return Navigator.of(context).pushReplacementNamed(AlbumBrowser.routeName,
|
||||
arguments: AlbumBrowserArguments(account, album));
|
||||
} else if (album.provider is AlbumSmartProvider) {
|
||||
return Navigator.of(context).pushReplacementNamed(
|
||||
SmartAlbumBrowser.routeName,
|
||||
arguments: SmartAlbumBrowserArguments(account, album));
|
||||
} else {
|
||||
return Navigator.of(context).pushReplacementNamed(
|
||||
DynamicAlbumBrowser.routeName,
|
||||
|
|
|
@ -13,6 +13,7 @@ import 'package:nc_photos/app_localizations.dart';
|
|||
import 'package:nc_photos/bloc/scan_account_dir.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/download_handler.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/data_source.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
|
@ -28,6 +29,7 @@ import 'package:nc_photos/share_handler.dart';
|
|||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/use_case/update_property.dart';
|
||||
import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util;
|
||||
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
|
||||
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
|
||||
import 'package:nc_photos/widget/home_app_bar.dart';
|
||||
|
@ -137,6 +139,8 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
_buildAppBar(context),
|
||||
if (_metadataTaskState != MetadataTaskState.idle)
|
||||
_buildMetadataTaskHeader(context),
|
||||
if (_smartAlbums.isNotEmpty)
|
||||
_buildSmartAlbumList(context),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
sliver: buildItemStreamList(
|
||||
|
@ -332,6 +336,35 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSmartAlbumList(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: _SmartAlbumItem.height,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: _smartAlbums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final a = _smartAlbums[index];
|
||||
final coverFile = a.coverProvider.getCover(a);
|
||||
return _SmartAlbumItem(
|
||||
account: widget.account,
|
||||
previewUrl: coverFile == null
|
||||
? null
|
||||
: api_util.getFilePreviewUrl(widget.account, coverFile,
|
||||
width: k.photoThumbSize, height: k.photoThumbSize),
|
||||
label: a.name,
|
||||
onTap: () {
|
||||
album_browser_util.push(context, widget.account, a);
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onStateChange(BuildContext context, ScanAccountDirBlocState state) {
|
||||
if (state is ScanAccountDirBlocInit) {
|
||||
itemStreamListItems = [];
|
||||
|
@ -521,6 +554,8 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
final dateHelper = photo_list_util.DateGroupHelper(
|
||||
isMonthOnly: isMonthOnly,
|
||||
);
|
||||
final today = DateTime.now();
|
||||
final memoryAlbumHelper = photo_list_util.MemoryAlbumHelper(today);
|
||||
itemStreamListItems = () sync* {
|
||||
for (int i = 0; i < _backingFiles.length; ++i) {
|
||||
final f = _backingFiles[i];
|
||||
|
@ -528,6 +563,7 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
if (date != null) {
|
||||
yield _DateListItem(date: date, isMonthOnly: isMonthOnly);
|
||||
}
|
||||
memoryAlbumHelper.addFile(f);
|
||||
|
||||
final previewUrl = api_util.getFilePreviewUrl(widget.account, f,
|
||||
width: k.photoThumbSize, height: k.photoThumbSize);
|
||||
|
@ -552,6 +588,8 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
}
|
||||
}()
|
||||
.toList();
|
||||
_smartAlbums = memoryAlbumHelper
|
||||
.build((year) => L10n.global().memoryAlbumName(today.year - year));
|
||||
}
|
||||
|
||||
void _reqQuery() {
|
||||
|
@ -588,14 +626,19 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
_metadataTaskState == MetadataTaskState.idle
|
||||
? 0
|
||||
: _metadataTaskHeaderHeight;
|
||||
// scroll extent = list height - widget viewport height + sliver app bar height + metadata task header height + list padding
|
||||
final smartAlbumListHeight =
|
||||
_smartAlbums.isNotEmpty ? _SmartAlbumItem.height : 0;
|
||||
// scroll extent = list height - widget viewport height
|
||||
// + sliver app bar height + metadata task header height
|
||||
// + smart album list height + list padding
|
||||
final scrollExtent = _itemListMaxExtent! -
|
||||
constraints.maxHeight +
|
||||
_appBarExtent! +
|
||||
metadataTaskHeaderExtent +
|
||||
smartAlbumListHeight +
|
||||
16;
|
||||
_log.info(
|
||||
"[_getScrollViewExtent] $_itemListMaxExtent - ${constraints.maxHeight} + $_appBarExtent + $metadataTaskHeaderExtent + 16 = $scrollExtent");
|
||||
"[_getScrollViewExtent] $_itemListMaxExtent - ${constraints.maxHeight} + $_appBarExtent + $metadataTaskHeaderExtent + $smartAlbumListHeight + 16 = $scrollExtent");
|
||||
return scrollExtent;
|
||||
} else {
|
||||
return null;
|
||||
|
@ -631,6 +674,7 @@ class _HomePhotosState extends State<HomePhotos>
|
|||
late final _bloc = ScanAccountDirBloc.of(widget.account);
|
||||
|
||||
var _backingFiles = <File>[];
|
||||
var _smartAlbums = <Album>[];
|
||||
|
||||
var _thumbZoomLevel = 0;
|
||||
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
|
||||
|
@ -805,6 +849,80 @@ class _MetadataTaskLoadingIcon extends AnimatedWidget {
|
|||
Animation<double> get _progress => listenable as Animation<double>;
|
||||
}
|
||||
|
||||
class _SmartAlbumItem extends StatelessWidget {
|
||||
static const width = 96.0;
|
||||
static const height = width * 1.15;
|
||||
|
||||
const _SmartAlbumItem({
|
||||
Key? key,
|
||||
required this.account,
|
||||
required this.previewUrl,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
PhotoListImage(
|
||||
account: account,
|
||||
previewUrl: previewUrl,
|
||||
padding: const EdgeInsets.all(0),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.center,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black87],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: AppTheme.primaryTextColorDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final String? previewUrl;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
}
|
||||
|
||||
enum _SelectionMenuOption {
|
||||
archive,
|
||||
delete,
|
||||
|
|
|
@ -26,6 +26,7 @@ import 'package:nc_photos/widget/shared_file_viewer.dart';
|
|||
import 'package:nc_photos/widget/sharing_browser.dart';
|
||||
import 'package:nc_photos/widget/sign_in.dart';
|
||||
import 'package:nc_photos/widget/slideshow_viewer.dart';
|
||||
import 'package:nc_photos/widget/smart_album_browser.dart';
|
||||
import 'package:nc_photos/widget/splash.dart';
|
||||
import 'package:nc_photos/widget/trashbin_browser.dart';
|
||||
import 'package:nc_photos/widget/trashbin_viewer.dart';
|
||||
|
@ -152,6 +153,7 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
|||
route ??= _handleAccountSettingsRoute(settings);
|
||||
route ??= _handleShareFolderPickerRoute(settings);
|
||||
route ??= _handleAlbumPickerRoute(settings);
|
||||
route ??= _handleSmartAlbumBrowserRoute(settings);
|
||||
return route;
|
||||
}
|
||||
|
||||
|
@ -454,6 +456,20 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
|||
return null;
|
||||
}
|
||||
|
||||
Route<dynamic>? _handleSmartAlbumBrowserRoute(RouteSettings settings) {
|
||||
try {
|
||||
if (settings.name == SmartAlbumBrowser.routeName &&
|
||||
settings.arguments != null) {
|
||||
final args = settings.arguments as SmartAlbumBrowserArguments;
|
||||
return SmartAlbumBrowser.buildRoute(args);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"[_handleSmartAlbumBrowserRoute] Failed while handling route", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
||||
|
|
|
@ -13,13 +13,14 @@ class PhotoListImage extends StatelessWidget {
|
|||
Key? key,
|
||||
required this.account,
|
||||
required this.previewUrl,
|
||||
this.padding = const EdgeInsets.all(2),
|
||||
this.isGif = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
padding: padding,
|
||||
child: FittedBox(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
fit: BoxFit.cover,
|
||||
|
@ -29,28 +30,37 @@ class PhotoListImage extends StatelessWidget {
|
|||
// arbitrary size here
|
||||
constraints: BoxConstraints.tight(const Size(128, 128)),
|
||||
color: AppTheme.getListItemBackgroundColor(context),
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: ThumbnailCacheManager.inst,
|
||||
imageUrl: previewUrl,
|
||||
httpHeaders: {
|
||||
"Authorization": Api.getAuthorizationHeaderValue(account),
|
||||
},
|
||||
fadeInDuration: const Duration(),
|
||||
filterQuality: FilterQuality.high,
|
||||
errorWidget: (context, url, error) {
|
||||
// won't work on web because the image is downloaded by the cache
|
||||
// manager instead
|
||||
// where's the preview???
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 64,
|
||||
color: Colors.white.withOpacity(.8),
|
||||
child: previewUrl == null
|
||||
? Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 64,
|
||||
color: Colors.white.withOpacity(.8),
|
||||
),
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
cacheManager: ThumbnailCacheManager.inst,
|
||||
imageUrl: previewUrl!,
|
||||
httpHeaders: {
|
||||
"Authorization":
|
||||
Api.getAuthorizationHeaderValue(account),
|
||||
},
|
||||
fadeInDuration: const Duration(),
|
||||
filterQuality: FilterQuality.high,
|
||||
errorWidget: (context, url, error) {
|
||||
// won't work on web because the image is downloaded by
|
||||
// the cache manager instead
|
||||
// where's the preview???
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 64,
|
||||
color: Colors.white.withOpacity(.8),
|
||||
),
|
||||
);
|
||||
},
|
||||
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
);
|
||||
},
|
||||
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
),
|
||||
if (isGif)
|
||||
Container(
|
||||
|
@ -71,8 +81,9 @@ class PhotoListImage extends StatelessWidget {
|
|||
}
|
||||
|
||||
final Account account;
|
||||
final String previewUrl;
|
||||
final String? previewUrl;
|
||||
final bool isGif;
|
||||
final EdgeInsetsGeometry padding;
|
||||
}
|
||||
|
||||
class PhotoListVideo extends StatelessWidget {
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/date_time_extension.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/album/cover_provider.dart';
|
||||
import 'package:nc_photos/entity/album/provider.dart';
|
||||
import 'package:nc_photos/entity/album/sort_provider.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/iterable_extension.dart';
|
||||
|
||||
class DateGroupHelper {
|
||||
DateGroupHelper({
|
||||
|
@ -19,6 +26,68 @@ class DateGroupHelper {
|
|||
DateTime? _currentDate;
|
||||
}
|
||||
|
||||
/// Build memory album from files
|
||||
///
|
||||
/// Feb 29 is treated as Mar 1 on non leap years
|
||||
class MemoryAlbumHelper {
|
||||
MemoryAlbumHelper([DateTime? today])
|
||||
: today = (today?.toLocal() ?? DateTime.now()).toMidnight();
|
||||
|
||||
void addFile(File f) {
|
||||
final date = f.bestDateTime.toLocal().toMidnight();
|
||||
final diff = today.difference(date).inDays;
|
||||
if (diff < 300) {
|
||||
return;
|
||||
}
|
||||
for (final dy in [0, -1, 1]) {
|
||||
if (today.copyWith(year: date.year + dy).difference(date).abs().inDays <=
|
||||
3) {
|
||||
_log.fine(
|
||||
"[addFile] Add file (${f.bestDateTime}) to ${date.year + dy}");
|
||||
_addFileToYear(f, date.year + dy);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build list of memory albums
|
||||
///
|
||||
/// [nameBuilder] is a function that return the name of the album for a
|
||||
/// particular year
|
||||
List<Album> build(String Function(int year) nameBuilder) {
|
||||
return _data.entries
|
||||
.sorted((a, b) => b.key.compareTo(a.key))
|
||||
.map((e) => Album(
|
||||
name: nameBuilder(e.key),
|
||||
provider: AlbumMemoryProvider(
|
||||
year: e.key, month: today.month, day: today.day),
|
||||
coverProvider:
|
||||
AlbumManualCoverProvider(coverFile: e.value.coverFile),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _addFileToYear(File f, int year) {
|
||||
final item = _data[year];
|
||||
final date = today.copyWith(year: year);
|
||||
if (item == null) {
|
||||
_data[year] = _MemoryAlbumHelperItem(date, f);
|
||||
} else {
|
||||
final coverDiff = _MemoryAlbumHelperItem.getCoverDiff(date, f);
|
||||
if (coverDiff < item.coverDiff) {
|
||||
item.coverFile = f;
|
||||
item.coverDiff = coverDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final DateTime today;
|
||||
final _data = <int, _MemoryAlbumHelperItem>{};
|
||||
|
||||
static final _log = Logger("widget.photo_list_util.MemoryAlbumHelper");
|
||||
}
|
||||
|
||||
int getThumbSize(int zoomLevel) {
|
||||
switch (zoomLevel) {
|
||||
case -1:
|
||||
|
@ -35,3 +104,15 @@ int getThumbSize(int zoomLevel) {
|
|||
return 112;
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoryAlbumHelperItem {
|
||||
_MemoryAlbumHelperItem(this.date, this.coverFile)
|
||||
: coverDiff = getCoverDiff(date, coverFile);
|
||||
|
||||
static Duration getCoverDiff(DateTime date, File f) =>
|
||||
f.bestDateTime.difference(date.copyWith(hour: 12)).abs();
|
||||
|
||||
final DateTime date;
|
||||
File coverFile;
|
||||
Duration coverDiff;
|
||||
}
|
||||
|
|
414
lib/widget/smart_album_browser.dart
Normal file
414
lib/widget/smart_album_browser.dart
Normal file
|
@ -0,0 +1,414 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||
import 'package:nc_photos/app_db.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/download_handler.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/album/item.dart';
|
||||
import 'package:nc_photos/entity/album/provider.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/share_handler.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/use_case/preprocess_album.dart';
|
||||
import 'package:nc_photos/widget/album_browser_mixin.dart';
|
||||
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
|
||||
import 'package:nc_photos/widget/photo_list_item.dart';
|
||||
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
|
||||
import 'package:nc_photos/widget/viewer.dart';
|
||||
|
||||
class SmartAlbumBrowserArguments {
|
||||
const SmartAlbumBrowserArguments(this.account, this.album);
|
||||
|
||||
final Account account;
|
||||
final Album album;
|
||||
}
|
||||
|
||||
class SmartAlbumBrowser extends StatefulWidget {
|
||||
static const routeName = "/smart-album-browser";
|
||||
|
||||
static Route buildRoute(SmartAlbumBrowserArguments args) => MaterialPageRoute(
|
||||
builder: (context) => SmartAlbumBrowser.fromArgs(args),
|
||||
);
|
||||
|
||||
const SmartAlbumBrowser({
|
||||
Key? key,
|
||||
required this.account,
|
||||
required this.album,
|
||||
}) : super(key: key);
|
||||
|
||||
SmartAlbumBrowser.fromArgs(SmartAlbumBrowserArguments args, {Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
account: args.account,
|
||||
album: args.album,
|
||||
);
|
||||
|
||||
@override
|
||||
createState() => _SmartAlbumBrowserState();
|
||||
|
||||
final Account account;
|
||||
final Album album;
|
||||
}
|
||||
|
||||
class _SmartAlbumBrowserState extends State<SmartAlbumBrowser>
|
||||
with
|
||||
SelectableItemStreamListMixin<SmartAlbumBrowser>,
|
||||
AlbumBrowserMixin<SmartAlbumBrowser> {
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_initAlbum();
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return AppTheme(
|
||||
child: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => _buildContent(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@protected
|
||||
get canEdit => false;
|
||||
|
||||
Future<void> _initAlbum() async {
|
||||
assert(widget.album.provider is AlbumSmartProvider);
|
||||
_log.info("[_initAlbum] ${widget.album}");
|
||||
final items = await PreProcessAlbum(AppDb())(widget.account, widget.album);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_album = widget.album;
|
||||
_transformItems(items);
|
||||
initCover(widget.account, widget.album);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
if (_album == null) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
buildNormalAppBar(context, widget.account, widget.album),
|
||||
const SliverToBoxAdapter(
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return buildItemStreamListOuter(
|
||||
context,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||
secondary: AppTheme.getOverscrollIndicatorColor(context),
|
||||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context),
|
||||
buildItemStreamList(
|
||||
maxCrossAxisExtent: thumbSize.toDouble(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
if (isSelectionMode) {
|
||||
return _buildSelectionAppBar(context);
|
||||
} else {
|
||||
return _buildNormalAppBar(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNormalAppBar(BuildContext context) {
|
||||
final menuItems = <PopupMenuEntry<int>>[
|
||||
PopupMenuItem(
|
||||
value: _menuValueDownload,
|
||||
child: Text(L10n.global().downloadTooltip),
|
||||
),
|
||||
];
|
||||
|
||||
return buildNormalAppBar(
|
||||
context,
|
||||
widget.account,
|
||||
_album!,
|
||||
menuItemBuilder: (_) => menuItems,
|
||||
onSelectedMenuItem: (option) {
|
||||
switch (option) {
|
||||
case _menuValueDownload:
|
||||
_onDownloadPressed();
|
||||
break;
|
||||
default:
|
||||
_log.shout("[_buildNormalAppBar] Unknown value: $option");
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionAppBar(BuildContext context) {
|
||||
return buildSelectionAppBar(context, [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: L10n.global().shareTooltip,
|
||||
onPressed: () {
|
||||
_onSelectionSharePressed(context);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: L10n.global().addToAlbumTooltip,
|
||||
onPressed: () => _onSelectionAddPressed(context),
|
||||
),
|
||||
PopupMenuButton<_SelectionMenuOption>(
|
||||
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _SelectionMenuOption.download,
|
||||
child: Text(L10n.global().downloadTooltip),
|
||||
),
|
||||
],
|
||||
onSelected: (option) => _onSelectionMenuSelected(context, option),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
void _onItemTap(int index) {
|
||||
// convert item index to file index
|
||||
var fileIndex = index;
|
||||
for (int i = 0; i < index; ++i) {
|
||||
if (_sortedItems[i] is! AlbumFileItem ||
|
||||
!file_util
|
||||
.isSupportedFormat((_sortedItems[i] as AlbumFileItem).file)) {
|
||||
--fileIndex;
|
||||
}
|
||||
}
|
||||
Navigator.pushNamed(context, Viewer.routeName,
|
||||
arguments: ViewerArguments(widget.account, _backingFiles, fileIndex,
|
||||
album: widget.album));
|
||||
}
|
||||
|
||||
void _onDownloadPressed() {
|
||||
DownloadHandler().downloadFiles(
|
||||
widget.account,
|
||||
_sortedItems.whereType<AlbumFileItem>().map((e) => e.file).toList(),
|
||||
parentDir: _album!.name,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSelectionMenuSelected(
|
||||
BuildContext context, _SelectionMenuOption option) {
|
||||
switch (option) {
|
||||
case _SelectionMenuOption.download:
|
||||
_onSelectionDownloadPressed();
|
||||
break;
|
||||
default:
|
||||
_log.shout("[_onSelectionMenuSelected] Unknown option: $option");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectionSharePressed(BuildContext context) {
|
||||
final selected = selectedListItems
|
||||
.whereType<_FileListItem>()
|
||||
.map((e) => e.file)
|
||||
.toList();
|
||||
ShareHandler(
|
||||
context: context,
|
||||
clearSelection: () {
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
},
|
||||
).shareFiles(widget.account, selected);
|
||||
}
|
||||
|
||||
Future<void> _onSelectionAddPressed(BuildContext context) async {
|
||||
return AddSelectionToAlbumHandler()(
|
||||
context: context,
|
||||
account: widget.account,
|
||||
selectedFiles: selectedListItems
|
||||
.whereType<_FileListItem>()
|
||||
.map((e) => e.file)
|
||||
.toList(),
|
||||
clearSelection: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onSelectionDownloadPressed() {
|
||||
final selected = selectedListItems
|
||||
.whereType<_FileListItem>()
|
||||
.map((e) => e.file)
|
||||
.toList();
|
||||
DownloadHandler().downloadFiles(widget.account, selected);
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
}
|
||||
|
||||
void _transformItems(List<AlbumItem> items) {
|
||||
// items come sorted for smart album
|
||||
_sortedItems = _album!.sortProvider.sort(items);
|
||||
_backingFiles = _sortedItems
|
||||
.whereType<AlbumFileItem>()
|
||||
.map((i) => i.file)
|
||||
.where((f) => file_util.isSupportedFormat(f))
|
||||
.toList();
|
||||
|
||||
itemStreamListItems = () sync* {
|
||||
for (int i = 0; i < _sortedItems.length; ++i) {
|
||||
final item = _sortedItems[i];
|
||||
if (item is AlbumFileItem) {
|
||||
final previewUrl = api_util.getFilePreviewUrl(
|
||||
widget.account,
|
||||
item.file,
|
||||
width: k.photoThumbSize,
|
||||
height: k.photoThumbSize,
|
||||
);
|
||||
|
||||
if (file_util.isSupportedImageFormat(item.file)) {
|
||||
yield _ImageListItem(
|
||||
index: i,
|
||||
file: item.file,
|
||||
account: widget.account,
|
||||
previewUrl: previewUrl,
|
||||
onTap: () => _onItemTap(i),
|
||||
);
|
||||
} else if (file_util.isSupportedVideoFormat(item.file)) {
|
||||
yield _VideoListItem(
|
||||
index: i,
|
||||
file: item.file,
|
||||
account: widget.account,
|
||||
previewUrl: previewUrl,
|
||||
onTap: () => _onItemTap(i),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Album? _album;
|
||||
var _sortedItems = <AlbumItem>[];
|
||||
var _backingFiles = <File>[];
|
||||
|
||||
static final _log =
|
||||
Logger("widget.smart_album_browser._SmartAlbumBrowserState");
|
||||
static const _menuValueDownload = 1;
|
||||
}
|
||||
|
||||
enum _SelectionMenuOption {
|
||||
download,
|
||||
}
|
||||
|
||||
abstract class _ListItem implements SelectableItem {
|
||||
const _ListItem({
|
||||
required this.index,
|
||||
VoidCallback? onTap,
|
||||
}) : _onTap = onTap;
|
||||
|
||||
@override
|
||||
get onTap => _onTap;
|
||||
|
||||
@override
|
||||
get isSelectable => true;
|
||||
|
||||
@override
|
||||
get staggeredTile => const StaggeredTile.count(1, 1);
|
||||
|
||||
@override
|
||||
toString() {
|
||||
return "$runtimeType {"
|
||||
"index: $index, "
|
||||
"}";
|
||||
}
|
||||
|
||||
final int index;
|
||||
|
||||
final VoidCallback? _onTap;
|
||||
}
|
||||
|
||||
abstract class _FileListItem extends _ListItem {
|
||||
_FileListItem({
|
||||
required int index,
|
||||
required this.file,
|
||||
VoidCallback? onTap,
|
||||
}) : super(
|
||||
index: index,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
final File file;
|
||||
}
|
||||
|
||||
class _ImageListItem extends _FileListItem {
|
||||
_ImageListItem({
|
||||
required int index,
|
||||
required File file,
|
||||
required this.account,
|
||||
required this.previewUrl,
|
||||
VoidCallback? onTap,
|
||||
}) : super(
|
||||
index: index,
|
||||
file: file,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
@override
|
||||
buildWidget(BuildContext context) {
|
||||
return PhotoListImage(
|
||||
account: account,
|
||||
previewUrl: previewUrl,
|
||||
isGif: file.contentType == "image/gif",
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final String previewUrl;
|
||||
}
|
||||
|
||||
class _VideoListItem extends _FileListItem {
|
||||
_VideoListItem({
|
||||
required int index,
|
||||
required File file,
|
||||
required this.account,
|
||||
required this.previewUrl,
|
||||
VoidCallback? onTap,
|
||||
}) : super(
|
||||
index: index,
|
||||
file: file,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
@override
|
||||
buildWidget(BuildContext context) {
|
||||
return PhotoListVideo(
|
||||
account: account,
|
||||
previewUrl: previewUrl,
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final String previewUrl;
|
||||
}
|
|
@ -1,13 +1,17 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/changelog.dart' as changelog;
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/pref.dart';
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/use_case/compat/v29.dart';
|
||||
import 'package:nc_photos/use_case/db_compat/v5.dart';
|
||||
import 'package:nc_photos/widget/home.dart';
|
||||
import 'package:nc_photos/widget/processing_dialog.dart';
|
||||
import 'package:nc_photos/widget/setup.dart';
|
||||
|
@ -37,6 +41,7 @@ class _SplashState extends State<Splash> {
|
|||
if (_shouldUpgrade()) {
|
||||
await _handleUpgrade();
|
||||
}
|
||||
await _migrateDb();
|
||||
_initTimedExit();
|
||||
}
|
||||
|
||||
|
@ -158,6 +163,37 @@ class _SplashState extends State<Splash> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateDb() async {
|
||||
bool isShowDialog = false;
|
||||
void showUpdateDialog() {
|
||||
if (!isShowDialog) {
|
||||
isShowDialog = true;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ProcessingDialog(
|
||||
text: L10n.global().migrateDatabaseProcessingNotification,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
if (await DbCompatV5.isNeedMigration(c.appDb)) {
|
||||
showUpdateDialog();
|
||||
try {
|
||||
await DbCompatV5.migrate(c.appDb);
|
||||
} catch (_) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(L10n.global().migrateDatabaseFailureNotification),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
}
|
||||
}
|
||||
if (isShowDialog) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
String _gatherChangelog(int from) {
|
||||
if (from < 100) {
|
||||
from *= 10;
|
||||
|
|
|
@ -73,6 +73,7 @@ class MockAppDb implements AppDb {
|
|||
bool hasAlbumStore = true,
|
||||
bool hasFileDb2Store = true,
|
||||
bool hasDirStore = true,
|
||||
bool hasMetaStore = true,
|
||||
// compat
|
||||
bool hasFileStore = false,
|
||||
bool hasFileDbStore = false,
|
||||
|
@ -88,6 +89,7 @@ class MockAppDb implements AppDb {
|
|||
hasAlbumStore: hasAlbumStore,
|
||||
hasFileDb2Store: hasFileDb2Store,
|
||||
hasDirStore: hasDirStore,
|
||||
hasMetaStore: hasMetaStore,
|
||||
hasFileStore: hasFileStore,
|
||||
hasFileDbStore: hasFileDbStore,
|
||||
);
|
||||
|
@ -120,6 +122,7 @@ class MockAppDb implements AppDb {
|
|||
bool hasAlbumStore = true,
|
||||
bool hasFileDb2Store = true,
|
||||
bool hasDirStore = true,
|
||||
bool hasMetaStore = true,
|
||||
// compat
|
||||
bool hasFileStore = false,
|
||||
bool hasFileDbStore = false,
|
||||
|
@ -133,10 +136,16 @@ class MockAppDb implements AppDb {
|
|||
final file2Store = db.createObjectStore(AppDb.file2StoreName);
|
||||
file2Store.createIndex(AppDbFile2Entry.strippedPathIndexName,
|
||||
AppDbFile2Entry.strippedPathKeyPath);
|
||||
file2Store.createIndex(AppDbFile2Entry.dateTimeEpochMsIndexName,
|
||||
AppDbFile2Entry.dateTimeEpochMsKeyPath);
|
||||
}
|
||||
if (hasDirStore) {
|
||||
db.createObjectStore(AppDb.dirStoreName);
|
||||
}
|
||||
if (hasMetaStore) {
|
||||
db.createObjectStore(AppDb.metaStoreName,
|
||||
keyPath: AppDbMetaEntry.keyPath);
|
||||
}
|
||||
|
||||
// compat
|
||||
if (hasFileStore) {
|
||||
|
|
389
test/widget/photo_list_util_test.dart
Normal file
389
test/widget/photo_list_util_test.dart
Normal file
|
@ -0,0 +1,389 @@
|
|||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/album/cover_provider.dart';
|
||||
import 'package:nc_photos/entity/album/provider.dart';
|
||||
import 'package:nc_photos/entity/album/sort_provider.dart';
|
||||
import 'package:nc_photos/or_null.dart';
|
||||
import 'package:nc_photos/widget/photo_list_util.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../test_util.dart' as util;
|
||||
|
||||
void main() {
|
||||
group("MemoryAlbumHelper", () {
|
||||
test("same year", _sameYear);
|
||||
test("next year", _nextYear);
|
||||
group("prev year", () {
|
||||
test("same day", _prevYear);
|
||||
test("-4 day", _prevYear4DaysBefore);
|
||||
test("-3 day", _prevYear3DaysBefore);
|
||||
test("+4 day", _prevYear4DaysAfter);
|
||||
test("+3 day", _prevYear3DaysAfter);
|
||||
});
|
||||
group("on feb 29", () {
|
||||
test("+feb 25", _onFeb29AddFeb25);
|
||||
test("+feb 26", _onFeb29AddFeb26);
|
||||
group("non leap year", () {
|
||||
test("+mar 5", _onFeb29AddMar5);
|
||||
test("+mar 4", _onFeb29AddMar4);
|
||||
});
|
||||
group("leap year", () {
|
||||
test("+mar 4", _onFeb29AddMar4LeapYear);
|
||||
test("+mar 3", _onFeb29AddMar3LeapYear);
|
||||
});
|
||||
});
|
||||
group("on jan 1", () {
|
||||
test("+dec 31", _onJan1AddDec31);
|
||||
test("+dec 31 a year ago", _onJan1AddDec31PrevYear);
|
||||
});
|
||||
group("on dec 31", () {
|
||||
test("+jan 1", _onDec31AddJan1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a file taken in the same year
|
||||
///
|
||||
/// Today: 2021-02-03
|
||||
/// File: 2021-02-01
|
||||
/// Expect: empty
|
||||
void _sameYear() {
|
||||
final today = DateTime(2021, 2, 3);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2021, 2, 3));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken in the next year. This happens if the user adjusted the
|
||||
/// system clock
|
||||
///
|
||||
/// Today: 2021-02-03
|
||||
/// File: 2022-02-03
|
||||
/// Expect: empty
|
||||
void _nextYear() {
|
||||
final today = DateTime(2021, 2, 3);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2022, 2, 3));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2021-02-03
|
||||
/// File: 2020-02-03
|
||||
/// Expect: [2020]
|
||||
void _prevYear() {
|
||||
final today = DateTime(2021, 2, 3);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2020, 2, 3));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2020",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2020, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2021-02-03
|
||||
/// File: 2020-01-30
|
||||
/// Expect: empty
|
||||
void _prevYear4DaysBefore() {
|
||||
final today = DateTime(2021, 2, 3);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2020, 1, 30));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2021-02-03
|
||||
/// File: 2020-01-31
|
||||
/// Expect: [2020]
|
||||
void _prevYear3DaysBefore() {
|
||||
final today = DateTime(2021, 2, 3);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2020, 1, 31));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2020",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2020, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2021-02-03
|
||||
/// File: 2020-01-30
|
||||
/// Expect: empty
|
||||
void _prevYear4DaysAfter() {
|
||||
final today = DateTime(2021, 2, 3);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2020, 2, 7));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2021-02-03
|
||||
/// File: 2020-01-31
|
||||
/// Expect: [2020]
|
||||
void _prevYear3DaysAfter() {
|
||||
final today = DateTime(2021, 2, 3);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2020, 2, 6));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2020",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2020, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2020-02-29
|
||||
/// File: 2019-02-25
|
||||
/// Expect: empty
|
||||
void _onFeb29AddFeb25() {
|
||||
final today = DateTime(2020, 2, 29);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2019, 2, 25));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2020-02-29
|
||||
/// File: 2019-02-26
|
||||
/// Expect: [2019]
|
||||
void _onFeb29AddFeb26() {
|
||||
final today = DateTime(2020, 2, 29);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2019, 2, 26));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2019",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2019, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2020-02-29
|
||||
/// File: 2019-03-05
|
||||
/// Expect: empty
|
||||
void _onFeb29AddMar5() {
|
||||
final today = DateTime(2020, 2, 29);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2019, 3, 5));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev year
|
||||
///
|
||||
/// Today: 2020-02-29
|
||||
/// File: 2019-03-04
|
||||
/// Expect: [2019]
|
||||
void _onFeb29AddMar4() {
|
||||
final today = DateTime(2020, 2, 29);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2019, 3, 4));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2019",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2019, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev leap year
|
||||
///
|
||||
/// Today: 2020-02-29
|
||||
/// File: 2016-03-04
|
||||
/// Expect: empty
|
||||
void _onFeb29AddMar4LeapYear() {
|
||||
final today = DateTime(2020, 2, 29);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2016, 3, 4));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken in the prev leap year
|
||||
///
|
||||
/// Today: 2020-02-29
|
||||
/// File: 2016-03-03
|
||||
/// Expect: [2016]
|
||||
void _onFeb29AddMar3LeapYear() {
|
||||
final today = DateTime(2020, 2, 29);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2016, 3, 3));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2016",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2016, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a file taken around new year's day
|
||||
///
|
||||
/// Today: 2020-01-01
|
||||
/// File: 2019-12-31
|
||||
/// Expect: empty
|
||||
void _onJan1AddDec31() {
|
||||
final today = DateTime(2020, 1, 1);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2019, 12, 31));
|
||||
obj.addFile(file);
|
||||
expect(obj.build(_nameBuilder), []);
|
||||
}
|
||||
|
||||
/// Add a file taken around new year's day
|
||||
///
|
||||
/// Today: 2020-01-01
|
||||
/// File: 2018-12-31
|
||||
/// Expect: [2019]
|
||||
void _onJan1AddDec31PrevYear() {
|
||||
final today = DateTime(2020, 1, 1);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2018, 12, 31));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2019",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2019, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Add a file taken around new year's day
|
||||
///
|
||||
/// Today: 2020-12-31
|
||||
/// File: 2020-01-01
|
||||
/// Expect: [2019]
|
||||
void _onDec31AddJan1() {
|
||||
final today = DateTime(2020, 12, 31);
|
||||
final obj = MemoryAlbumHelper(today);
|
||||
final file = util.buildJpegFile(
|
||||
path: "", fileId: 0, lastModified: DateTime.utc(2020, 1, 1));
|
||||
obj.addFile(file);
|
||||
expect(
|
||||
obj
|
||||
.build(_nameBuilder)
|
||||
.map((a) => a.copyWith(lastUpdated: OrNull(DateTime(2021))))
|
||||
.toList(),
|
||||
[
|
||||
Album(
|
||||
name: "2019",
|
||||
provider:
|
||||
AlbumMemoryProvider(year: 2019, month: today.month, day: today.day),
|
||||
coverProvider: AlbumManualCoverProvider(coverFile: file),
|
||||
sortProvider: const AlbumTimeSortProvider(isAscending: false),
|
||||
lastUpdated: DateTime(2021),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _nameBuilder(int year) => "$year";
|
Loading…
Reference in a new issue