Add memory album

This commit is contained in:
Ming Ming 2022-01-15 18:35:15 +08:00
parent 0993324488
commit 042a927a2f
19 changed files with 1445 additions and 38 deletions

View file

@ -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;
}

View 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);
}

View file

@ -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;
}

View file

@ -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.

View file

@ -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": {

View file

@ -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"
]
}

View file

@ -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

View 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");
}

View file

@ -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");

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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 {

View file

@ -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;
}

View 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;
}

View file

@ -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;

View file

@ -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) {

View 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";