Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
310
mobile/lib/utils/action_button.utils.dart
Normal file
310
mobile/lib/utils/action_button.utils.dart
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class ActionButtonContext {
|
||||
final BaseAsset asset;
|
||||
final bool isOwner;
|
||||
final bool isArchived;
|
||||
final bool isTrashEnabled;
|
||||
final bool isInLockedView;
|
||||
final bool isStacked;
|
||||
final RemoteAlbum? currentAlbum;
|
||||
final bool advancedTroubleshooting;
|
||||
final ActionSource source;
|
||||
final bool isCasting;
|
||||
final TimelineOrigin timelineOrigin;
|
||||
final ThemeData? originalTheme;
|
||||
|
||||
const ActionButtonContext({
|
||||
required this.asset,
|
||||
required this.isOwner,
|
||||
required this.isArchived,
|
||||
required this.isTrashEnabled,
|
||||
required this.isStacked,
|
||||
required this.isInLockedView,
|
||||
required this.currentAlbum,
|
||||
required this.advancedTroubleshooting,
|
||||
required this.source,
|
||||
this.isCasting = false,
|
||||
this.timelineOrigin = TimelineOrigin.main,
|
||||
this.originalTheme,
|
||||
});
|
||||
}
|
||||
|
||||
enum ActionButtonType {
|
||||
openInfo,
|
||||
likeActivity,
|
||||
share,
|
||||
shareLink,
|
||||
cast,
|
||||
similarPhotos,
|
||||
viewInTimeline,
|
||||
download,
|
||||
upload,
|
||||
unstack,
|
||||
archive,
|
||||
unarchive,
|
||||
moveToLockFolder,
|
||||
removeFromLockFolder,
|
||||
removeFromAlbum,
|
||||
trash,
|
||||
deleteLocal,
|
||||
deletePermanent,
|
||||
delete,
|
||||
advancedInfo;
|
||||
|
||||
bool shouldShow(ActionButtonContext context) {
|
||||
return switch (this) {
|
||||
ActionButtonType.advancedInfo => context.advancedTroubleshooting,
|
||||
ActionButtonType.share => true,
|
||||
ActionButtonType.shareLink =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.archive =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
!context.isArchived,
|
||||
ActionButtonType.unarchive =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isArchived,
|
||||
ActionButtonType.download =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
!context.asset.hasLocal,
|
||||
ActionButtonType.trash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isTrashEnabled,
|
||||
ActionButtonType.deletePermanent =>
|
||||
context.isOwner && //
|
||||
context.asset.hasRemote && //
|
||||
!context.isTrashEnabled ||
|
||||
context.isInLockedView,
|
||||
ActionButtonType.delete =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.moveToLockFolder =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.removeFromLockFolder =>
|
||||
context.isOwner && //
|
||||
context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.deleteLocal =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasLocal,
|
||||
ActionButtonType.upload =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.storage == AssetState.local,
|
||||
ActionButtonType.removeFromAlbum =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null,
|
||||
ActionButtonType.unstack =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.isStacked,
|
||||
ActionButtonType.likeActivity =>
|
||||
!context.isInLockedView &&
|
||||
context.currentAlbum != null &&
|
||||
context.currentAlbum!.isActivityEnabled &&
|
||||
context.currentAlbum!.isShared,
|
||||
ActionButtonType.similarPhotos =>
|
||||
!context.isInLockedView && //
|
||||
context.asset is RemoteAsset,
|
||||
ActionButtonType.openInfo => true,
|
||||
ActionButtonType.viewInTimeline =>
|
||||
context.timelineOrigin != TimelineOrigin.main &&
|
||||
context.timelineOrigin != TimelineOrigin.deepLink &&
|
||||
context.timelineOrigin != TimelineOrigin.trash &&
|
||||
context.timelineOrigin != TimelineOrigin.lockedFolder &&
|
||||
context.timelineOrigin != TimelineOrigin.archive &&
|
||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||
context.isOwner,
|
||||
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||
};
|
||||
}
|
||||
|
||||
ConsumerWidget buildButton(
|
||||
ActionButtonContext context, [
|
||||
BuildContext? buildContext,
|
||||
bool iconOnly = false,
|
||||
bool menuItem = false,
|
||||
]) {
|
||||
return switch (this) {
|
||||
ActionButtonType.advancedInfo => AdvancedInfoActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.shareLink => ShareLinkActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unarchive => UnArchiveActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.deleteLocal => DeleteLocalActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
|
||||
albumId: context.currentAlbum!.id,
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
||||
assetId: (context.asset as RemoteAsset).id,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.openInfo => BaseActionButton(
|
||||
label: 'info'.tr(),
|
||||
iconData: Icons.info_outline,
|
||||
iconColor: context.originalTheme?.iconTheme.color,
|
||||
menuItem: true,
|
||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||
),
|
||||
ActionButtonType.viewInTimeline => BaseActionButton(
|
||||
label: 'view_in_timeline'.tr(),
|
||||
iconData: Icons.image_search,
|
||||
iconColor: context.originalTheme?.iconTheme.color,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: buildContext == null
|
||||
? null
|
||||
: () async {
|
||||
await buildContext.maybePop();
|
||||
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
||||
},
|
||||
),
|
||||
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
};
|
||||
}
|
||||
|
||||
/// Defines which group each button belongs to for kebab menu.
|
||||
/// Buttons in the same group will be displayed together,
|
||||
/// with dividers separating different groups.
|
||||
int get kebabMenuGroup => switch (this) {
|
||||
// 0: info
|
||||
ActionButtonType.openInfo => 0,
|
||||
// 10: move,remove, and delete
|
||||
ActionButtonType.trash => 10,
|
||||
ActionButtonType.deletePermanent => 10,
|
||||
ActionButtonType.removeFromLockFolder => 10,
|
||||
ActionButtonType.removeFromAlbum => 10,
|
||||
ActionButtonType.unstack => 10,
|
||||
ActionButtonType.archive => 10,
|
||||
ActionButtonType.unarchive => 10,
|
||||
ActionButtonType.moveToLockFolder => 10,
|
||||
ActionButtonType.deleteLocal => 10,
|
||||
ActionButtonType.delete => 10,
|
||||
// 90: advancedInfo
|
||||
ActionButtonType.advancedInfo => 90,
|
||||
// 1: others
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
class ActionButtonBuilder {
|
||||
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
||||
static const List<ActionButtonType> defaultViewerKebabMenuOrder = _actionTypes;
|
||||
static const Set<ActionButtonType> defaultViewerBottomBarButtons = {
|
||||
ActionButtonType.share,
|
||||
ActionButtonType.moveToLockFolder,
|
||||
ActionButtonType.upload,
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
};
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||
final visibleButtons = defaultViewerKebabMenuOrder
|
||||
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||
.toList();
|
||||
|
||||
if (visibleButtons.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<Widget> result = [];
|
||||
int? lastGroup;
|
||||
|
||||
for (final type in visibleButtons) {
|
||||
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||
result.add(const Divider(height: 1));
|
||||
}
|
||||
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
|
||||
lastGroup = type.kebabMenuGroup;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
25
mobile/lib/utils/album_filter.utils.dart
Normal file
25
mobile/lib/utils/album_filter.utils.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
class AlbumFilter {
|
||||
String? userId;
|
||||
String? query;
|
||||
QuickFilterMode mode;
|
||||
|
||||
AlbumFilter({required this.mode, this.userId, this.query});
|
||||
|
||||
AlbumFilter copyWith({String? userId, String? query, QuickFilterMode? mode}) {
|
||||
return AlbumFilter(userId: userId ?? this.userId, query: query ?? this.query, mode: mode ?? this.mode);
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumSort {
|
||||
AlbumSortMode mode;
|
||||
bool isReverse;
|
||||
|
||||
AlbumSort({required this.mode, this.isReverse = false});
|
||||
|
||||
AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) {
|
||||
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
|
||||
}
|
||||
}
|
||||
21
mobile/lib/utils/async_mutex.dart
Normal file
21
mobile/lib/utils/async_mutex.dart
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import 'dart:async';
|
||||
|
||||
/// Async mutex to guarantee actions are performed sequentially and do not interleave
|
||||
class AsyncMutex {
|
||||
Future _running = Future.value(null);
|
||||
int _enqueued = 0;
|
||||
|
||||
int get enqueued => _enqueued;
|
||||
|
||||
/// Execute [operation] exclusively, after any currently running operations.
|
||||
/// Returns a [Future] with the result of the [operation].
|
||||
Future<T> run<T>(Future<T> Function() operation) {
|
||||
final completer = Completer<T>();
|
||||
_enqueued++;
|
||||
_running.whenComplete(() {
|
||||
_enqueued--;
|
||||
completer.complete(Future<T>.sync(operation));
|
||||
});
|
||||
return _running = completer.future;
|
||||
}
|
||||
}
|
||||
83
mobile/lib/utils/backup_progress.dart
Normal file
83
mobile/lib/utils/backup_progress.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
final NumberFormat numberFormat = NumberFormat("###0.##");
|
||||
|
||||
String formatAssetBackupProgress(int uploadedAssets, int assetsToUpload) {
|
||||
final int percent = (uploadedAssets * 100) ~/ assetsToUpload;
|
||||
return "$percent% ($uploadedAssets/$assetsToUpload)";
|
||||
}
|
||||
|
||||
/// prints progress in useful (kilo/mega/giga)bytes
|
||||
String humanReadableFileBytesProgress(int bytes, int bytesTotal) {
|
||||
String unit = "KB";
|
||||
|
||||
if (bytesTotal >= 0x40000000) {
|
||||
unit = "GB";
|
||||
bytes >>= 20;
|
||||
bytesTotal >>= 20;
|
||||
} else if (bytesTotal >= 0x100000) {
|
||||
unit = "MB";
|
||||
bytes >>= 10;
|
||||
bytesTotal >>= 10;
|
||||
} else if (bytesTotal < 0x400) {
|
||||
return "${(bytes).toStringAsFixed(2)} B / ${(bytesTotal).toStringAsFixed(2)} B";
|
||||
}
|
||||
|
||||
return "${(bytes / 1024.0).toStringAsFixed(2)} $unit / ${(bytesTotal / 1024.0).toStringAsFixed(2)} $unit";
|
||||
}
|
||||
|
||||
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
|
||||
String humanReadableBytesProgress(int bytes, int bytesTotal) {
|
||||
String unit = "KB"; // Kilobyte
|
||||
if (bytesTotal >= 0x40000000) {
|
||||
unit = "GB"; // Gigabyte
|
||||
bytes >>= 20;
|
||||
bytesTotal >>= 20;
|
||||
} else if (bytesTotal >= 0x100000) {
|
||||
unit = "MB"; // Megabyte
|
||||
bytes >>= 10;
|
||||
bytesTotal >>= 10;
|
||||
} else if (bytesTotal < 0x400) {
|
||||
return "$bytes / $bytesTotal B";
|
||||
}
|
||||
final int percent = (bytes * 100) ~/ bytesTotal;
|
||||
final String done = numberFormat.format(bytes / 1024.0);
|
||||
final String total = numberFormat.format(bytesTotal / 1024.0);
|
||||
return "$percent% ($done/$total$unit)";
|
||||
}
|
||||
|
||||
class ThrottleProgressUpdate {
|
||||
ThrottleProgressUpdate(this._fun, Duration interval) : _interval = interval.inMicroseconds;
|
||||
final void Function(String?, int, int) _fun;
|
||||
final int _interval;
|
||||
int _invokedAt = 0;
|
||||
Timer? _timer;
|
||||
|
||||
String? title;
|
||||
int progress = 0;
|
||||
int total = 0;
|
||||
|
||||
void call({final String? title, final int progress = 0, final int total = 0}) {
|
||||
final time = Timeline.now;
|
||||
this.title = title ?? this.title;
|
||||
this.progress = progress;
|
||||
this.total = total;
|
||||
if (time > _invokedAt + _interval) {
|
||||
_timer?.cancel();
|
||||
_onTimeElapsed();
|
||||
} else {
|
||||
_timer ??= Timer(Duration(microseconds: _interval), _onTimeElapsed);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTimeElapsed() {
|
||||
_invokedAt = Timeline.now;
|
||||
_fun(title, progress, total);
|
||||
_timer = null;
|
||||
// clear title to not send/overwrite it next time if unchanged
|
||||
title = null;
|
||||
}
|
||||
}
|
||||
113
mobile/lib/utils/bootstrap.dart
Normal file
113
mobile/lib/utils/bootstrap.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
void configureFileDownloaderNotifications() {
|
||||
FileDownloader().configureNotificationForGroup(
|
||||
kDownloadGroupImage,
|
||||
running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'),
|
||||
complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'),
|
||||
progressBar: true,
|
||||
);
|
||||
|
||||
FileDownloader().configureNotificationForGroup(
|
||||
kDownloadGroupVideo,
|
||||
running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'),
|
||||
complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'),
|
||||
progressBar: true,
|
||||
);
|
||||
|
||||
FileDownloader().configureNotificationForGroup(
|
||||
kManualUploadGroup,
|
||||
running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()),
|
||||
complete: TaskNotification('upload_finished'.t(), 'backup_background_service_complete_notification'.t()),
|
||||
groupNotificationId: kManualUploadGroup,
|
||||
);
|
||||
|
||||
FileDownloader().configureNotificationForGroup(
|
||||
kBackupGroup,
|
||||
running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()),
|
||||
complete: TaskNotification('upload_finished'.t(), 'backup_background_service_complete_notification'.t()),
|
||||
groupNotificationId: kBackupGroup,
|
||||
);
|
||||
}
|
||||
|
||||
abstract final class Bootstrap {
|
||||
static Future<(Isar isar, Drift drift, DriftLogger logDb)> initDB() async {
|
||||
final drift = Drift();
|
||||
final logDb = DriftLogger();
|
||||
|
||||
Isar? isar = Isar.getInstance();
|
||||
|
||||
if (isar != null) {
|
||||
return (isar, drift, logDb);
|
||||
}
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
isar = await Isar.open(
|
||||
[
|
||||
StoreValueSchema,
|
||||
AssetSchema,
|
||||
AlbumSchema,
|
||||
ExifInfoSchema,
|
||||
UserSchema,
|
||||
BackupAlbumSchema,
|
||||
DuplicatedAssetSchema,
|
||||
ETagSchema,
|
||||
if (Platform.isAndroid) AndroidDeviceAssetSchema,
|
||||
if (Platform.isIOS) IOSDeviceAssetSchema,
|
||||
DeviceAssetEntitySchema,
|
||||
],
|
||||
directory: dir.path,
|
||||
maxSizeMiB: 2048,
|
||||
inspector: kDebugMode,
|
||||
);
|
||||
|
||||
return (isar, drift, logDb);
|
||||
}
|
||||
|
||||
static Future<void> initDomain(
|
||||
Isar db,
|
||||
Drift drift,
|
||||
DriftLogger logDb, {
|
||||
bool listenStoreUpdates = true,
|
||||
bool shouldBufferLogs = true,
|
||||
}) async {
|
||||
final isBeta = await IsarStoreRepository(db).tryGet(StoreKey.betaTimeline) ?? true;
|
||||
final IStoreRepository storeRepo = isBeta ? DriftStoreRepository(drift) : IsarStoreRepository(db);
|
||||
|
||||
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
|
||||
|
||||
await LogService.init(
|
||||
logRepository: LogRepository(logDb),
|
||||
storeRepository: storeRepo,
|
||||
shouldBuffer: shouldBufferLogs,
|
||||
);
|
||||
|
||||
await NetworkRepository.init();
|
||||
}
|
||||
}
|
||||
25
mobile/lib/utils/bytes_units.dart
Normal file
25
mobile/lib/utils/bytes_units.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'dart:math';
|
||||
|
||||
String formatBytes(int bytes) {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
|
||||
|
||||
int magnitude = 0;
|
||||
double remainder = bytes.toDouble();
|
||||
while (remainder >= 1024) {
|
||||
if (magnitude + 1 < units.length) {
|
||||
magnitude++;
|
||||
remainder /= 1024;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return "${remainder.toStringAsFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}";
|
||||
}
|
||||
|
||||
String formatHumanReadableBytes(int bytes, int decimals) {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const suffixes = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
var i = (log(bytes) / log(1024)).floor();
|
||||
return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}';
|
||||
}
|
||||
86
mobile/lib/utils/cache/custom_image_cache.dart
vendored
Normal file
86
mobile/lib/utils/cache/custom_image_cache.dart
vendored
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
|
||||
/// [ImageCache] that uses two caches for small and large images
|
||||
/// so that a single large image does not evict all small images
|
||||
final class CustomImageCache implements ImageCache {
|
||||
final _thumbhash = ImageCache()..maximumSize = 0;
|
||||
final _small = ImageCache();
|
||||
final _large = ImageCache()..maximumSize = 5; // Maximum 5 images
|
||||
|
||||
@override
|
||||
int get maximumSize => _small.maximumSize + _large.maximumSize;
|
||||
|
||||
@override
|
||||
int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes;
|
||||
|
||||
@override
|
||||
set maximumSize(int value) => _small.maximumSize = value;
|
||||
|
||||
@override
|
||||
set maximumSizeBytes(int value) => _small.maximumSize = value;
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
_small.clear();
|
||||
_large.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void clearLiveImages() {
|
||||
_small.clearLiveImages();
|
||||
_large.clearLiveImages();
|
||||
}
|
||||
|
||||
/// Gets the cache for the given key
|
||||
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
|
||||
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
|
||||
ImageCache _cacheForKey(Object key) {
|
||||
return switch (key) {
|
||||
ImmichLocalImageProvider() ||
|
||||
ImmichRemoteImageProvider() ||
|
||||
LocalFullImageProvider() ||
|
||||
RemoteFullImageProvider() => _large,
|
||||
ThumbHashProvider() => _thumbhash,
|
||||
_ => _small,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool containsKey(Object key) {
|
||||
// [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] are both
|
||||
// large size images while the other thumbnail providers are small
|
||||
return _cacheForKey(key).containsKey(key);
|
||||
}
|
||||
|
||||
@override
|
||||
int get currentSize => _small.currentSize + _large.currentSize;
|
||||
|
||||
@override
|
||||
int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes;
|
||||
|
||||
@override
|
||||
bool evict(Object key, {bool includeLive = true}) => _cacheForKey(key).evict(key, includeLive: includeLive);
|
||||
|
||||
@override
|
||||
int get liveImageCount => _small.liveImageCount + _large.liveImageCount;
|
||||
|
||||
@override
|
||||
int get pendingImageCount => _small.pendingImageCount + _large.pendingImageCount;
|
||||
|
||||
@override
|
||||
ImageStreamCompleter? putIfAbsent(
|
||||
Object key,
|
||||
ImageStreamCompleter Function() loader, {
|
||||
ImageErrorListener? onError,
|
||||
}) => _cacheForKey(key).putIfAbsent(key, loader, onError: onError);
|
||||
|
||||
@override
|
||||
ImageCacheStatus statusForKey(Object key) => _cacheForKey(key).statusForKey(key);
|
||||
}
|
||||
8
mobile/lib/utils/cache/widgets_binding.dart
vendored
Normal file
8
mobile/lib/utils/cache/widgets_binding.dart
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'custom_image_cache.dart';
|
||||
|
||||
final class ImmichWidgetsBinding extends WidgetsFlutterBinding {
|
||||
@override
|
||||
ImageCache createImageCache() => CustomImageCache();
|
||||
}
|
||||
99
mobile/lib/utils/color_filter_generator.dart
Normal file
99
mobile/lib/utils/color_filter_generator.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InvertionFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
const InvertionFilter({super.key, this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: const ColorFilter.matrix(<double>[
|
||||
-1, 0, 0, 0, 255, //
|
||||
0, -1, 0, 0, 255, //
|
||||
0, 0, -1, 0, 255, //
|
||||
0, 0, 0, 1, 0, //
|
||||
]),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - darkest, 1 - brightest, 0 - unchanged
|
||||
class BrightnessFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double brightness;
|
||||
const BrightnessFilter({super.key, this.child, this.brightness = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(_ColorFilterGenerator.brightnessAdjustMatrix(brightness)),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - greyscale, 1 - most saturated, 0 - unchanged
|
||||
class SaturationFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double saturation;
|
||||
const SaturationFilter({super.key, this.child, this.saturation = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(_ColorFilterGenerator.saturationAdjustMatrix(saturation)),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorFilterGenerator {
|
||||
static List<double> brightnessAdjustMatrix(double value) {
|
||||
value = value * 10;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
return List<double>.from(<double>[
|
||||
1, 0, 0, 0, value, 0, 1, 0, 0, value, 0, 0, 1, 0, value, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
|
||||
static List<double> saturationAdjustMatrix(double value) {
|
||||
value = value * 100;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
double x = ((1 + ((value > 0) ? ((3 * value) / 100) : (value / 100)))).toDouble();
|
||||
double lumR = 0.3086;
|
||||
double lumG = 0.6094;
|
||||
double lumB = 0.082;
|
||||
|
||||
return List<double>.from(<double>[
|
||||
(lumR * (1 - x)) + x, lumG * (1 - x), lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
(lumG * (1 - x)) + x, //
|
||||
lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
lumG * (1 - x), //
|
||||
(lumB * (1 - x)) + x, //
|
||||
0, 0, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
}
|
||||
2
mobile/lib/utils/datetime_comparison.dart
Normal file
2
mobile/lib/utils/datetime_comparison.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
bool isAtSameMomentAs(DateTime? a, DateTime? b) =>
|
||||
(a == null && b == null) || ((a != null && b != null) && a.isAtSameMomentAs(b));
|
||||
19
mobile/lib/utils/datetime_helpers.dart
Normal file
19
mobile/lib/utils/datetime_helpers.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
|
||||
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
|
||||
|
||||
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch, {bool isUtc = false}) {
|
||||
if (secondsSinceEpoch == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final milliSeconds = secondsSinceEpoch * 1000;
|
||||
if (milliSeconds < _minMillisecondsSinceEpoch || milliSeconds > _maxMillisecondsSinceEpoch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime.fromMillisecondsSinceEpoch(milliSeconds, isUtc: isUtc);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
95
mobile/lib/utils/debounce.dart
Normal file
95
mobile/lib/utils/debounce.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Used to debounce function calls with the [interval] provided.
|
||||
/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied.
|
||||
class Debouncer {
|
||||
Debouncer({required this.interval, this.maxWaitTime});
|
||||
final Duration interval;
|
||||
final Duration? maxWaitTime;
|
||||
Timer? _timer;
|
||||
FutureOr<void> Function()? _lastAction;
|
||||
DateTime? _lastActionTime;
|
||||
Future<void>? _actionFuture;
|
||||
|
||||
void run(FutureOr<void> Function() action) {
|
||||
_lastAction = action;
|
||||
_timer?.cancel();
|
||||
|
||||
if (maxWaitTime != null &&
|
||||
// _actionFuture == null && // TODO: should this check be here?
|
||||
(_lastActionTime == null || DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) {
|
||||
_callAndRest();
|
||||
return;
|
||||
}
|
||||
_timer = Timer(interval, _callAndRest);
|
||||
}
|
||||
|
||||
Future<void>? drain() {
|
||||
final timer = _timer;
|
||||
if (timer != null && timer.isActive) {
|
||||
timer.cancel();
|
||||
if (_lastAction != null) {
|
||||
_callAndRest();
|
||||
}
|
||||
}
|
||||
return _actionFuture;
|
||||
}
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
void _callAndRest() {
|
||||
_lastActionTime = DateTime.now();
|
||||
final action = _lastAction;
|
||||
_lastAction = null;
|
||||
|
||||
final result = action!();
|
||||
if (result is Future) {
|
||||
_actionFuture = result.whenComplete(() {
|
||||
_actionFuture = null;
|
||||
});
|
||||
}
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_lastAction = null;
|
||||
_lastActionTime = null;
|
||||
_actionFuture = null;
|
||||
}
|
||||
|
||||
bool get isActive => _actionFuture != null || (_timer != null && _timer!.isActive);
|
||||
}
|
||||
|
||||
/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
|
||||
/// default interval of 300ms is used to debounce the function calls
|
||||
Debouncer useDebouncer({
|
||||
Duration interval = const Duration(milliseconds: 300),
|
||||
Duration? maxWaitTime,
|
||||
List<Object?>? keys,
|
||||
}) => use(_DebouncerHook(interval: interval, maxWaitTime: maxWaitTime, keys: keys));
|
||||
|
||||
class _DebouncerHook extends Hook<Debouncer> {
|
||||
const _DebouncerHook({required this.interval, this.maxWaitTime, super.keys});
|
||||
|
||||
final Duration interval;
|
||||
final Duration? maxWaitTime;
|
||||
|
||||
@override
|
||||
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
|
||||
}
|
||||
|
||||
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
|
||||
late final debouncer = Debouncer(interval: hook.interval, maxWaitTime: hook.maxWaitTime);
|
||||
|
||||
@override
|
||||
Debouncer build(_) => debouncer;
|
||||
|
||||
@override
|
||||
void dispose() => debouncer.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useDebouncer';
|
||||
}
|
||||
8
mobile/lib/utils/debug_print.dart
Normal file
8
mobile/lib/utils/debug_print.dart
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
void dPrint(String Function() message) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(message());
|
||||
}
|
||||
}
|
||||
91
mobile/lib/utils/diff.dart
Normal file
91
mobile/lib/utils/diff.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
/// Efficiently compares two sorted lists in O(n), calling the given callback
|
||||
/// for each item.
|
||||
/// Return `true` if there are any differences found, else `false`
|
||||
Future<bool> diffSortedLists<T>(
|
||||
List<T> la,
|
||||
List<T> lb, {
|
||||
required int Function(T a, T b) compare,
|
||||
required FutureOr<bool> Function(T a, T b) both,
|
||||
required FutureOr<void> Function(T a) onlyFirst,
|
||||
required FutureOr<void> Function(T b) onlySecond,
|
||||
}) async {
|
||||
assert(la.isSorted(compare), "first argument must be sorted");
|
||||
assert(lb.isSorted(compare), "second argument must be sorted");
|
||||
bool diff = false;
|
||||
int i = 0, j = 0;
|
||||
for (; i < la.length && j < lb.length;) {
|
||||
final int order = compare(la[i], lb[j]);
|
||||
if (order == 0) {
|
||||
diff |= await both(la[i++], lb[j++]);
|
||||
} else if (order < 0) {
|
||||
await onlyFirst(la[i++]);
|
||||
diff = true;
|
||||
} else if (order > 0) {
|
||||
await onlySecond(lb[j++]);
|
||||
diff = true;
|
||||
}
|
||||
}
|
||||
diff |= i < la.length || j < lb.length;
|
||||
for (; i < la.length; i++) {
|
||||
await onlyFirst(la[i]);
|
||||
}
|
||||
for (; j < lb.length; j++) {
|
||||
await onlySecond(lb[j]);
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
/// Efficiently compares two sorted lists in O(n), calling the given callback
|
||||
/// for each item.
|
||||
/// Return `true` if there are any differences found, else `false`
|
||||
bool diffSortedListsSync<T>(
|
||||
List<T> la,
|
||||
List<T> lb, {
|
||||
required int Function(T a, T b) compare,
|
||||
required bool Function(T a, T b) both,
|
||||
required void Function(T a) onlyFirst,
|
||||
required void Function(T b) onlySecond,
|
||||
}) {
|
||||
assert(la.isSorted(compare), "first argument must be sorted");
|
||||
assert(lb.isSorted(compare), "second argument must be sorted");
|
||||
bool diff = false;
|
||||
int i = 0, j = 0;
|
||||
for (; i < la.length && j < lb.length;) {
|
||||
final int order = compare(la[i], lb[j]);
|
||||
if (order == 0) {
|
||||
diff |= both(la[i++], lb[j++]);
|
||||
} else if (order < 0) {
|
||||
onlyFirst(la[i++]);
|
||||
diff = true;
|
||||
} else if (order > 0) {
|
||||
onlySecond(lb[j++]);
|
||||
diff = true;
|
||||
}
|
||||
}
|
||||
diff |= i < la.length || j < lb.length;
|
||||
for (; i < la.length; i++) {
|
||||
onlyFirst(la[i]);
|
||||
}
|
||||
for (; j < lb.length; j++) {
|
||||
onlySecond(lb[j]);
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
int compareToNullable<T extends Comparable>(T? a, T? b) {
|
||||
if (a == null && b == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (a == null) {
|
||||
return 1;
|
||||
}
|
||||
if (b == null) {
|
||||
return -1;
|
||||
}
|
||||
return a.compareTo(b);
|
||||
}
|
||||
32
mobile/lib/utils/draggable_scroll_controller.dart
Normal file
32
mobile/lib/utils/draggable_scroll_controller.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Creates a [DraggableScrollableController] that will be disposed automatically.
|
||||
///
|
||||
/// See also:
|
||||
/// - [DraggableScrollableController]
|
||||
DraggableScrollableController useDraggableScrollController({List<Object?>? keys}) {
|
||||
return use(_DraggableScrollControllerHook(keys: keys));
|
||||
}
|
||||
|
||||
class _DraggableScrollControllerHook extends Hook<DraggableScrollableController> {
|
||||
const _DraggableScrollControllerHook({super.keys});
|
||||
|
||||
@override
|
||||
HookState<DraggableScrollableController, Hook<DraggableScrollableController>> createState() =>
|
||||
_DraggableScrollControllerHookState();
|
||||
}
|
||||
|
||||
class _DraggableScrollControllerHookState
|
||||
extends HookState<DraggableScrollableController, _DraggableScrollControllerHook> {
|
||||
late final controller = DraggableScrollableController();
|
||||
|
||||
@override
|
||||
DraggableScrollableController build(BuildContext context) => controller;
|
||||
|
||||
@override
|
||||
void dispose() => controller.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useDraggableScrollController';
|
||||
}
|
||||
15
mobile/lib/utils/hash.dart
Normal file
15
mobile/lib/utils/hash.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/// FNV-1a 64bit hash algorithm optimized for Dart Strings
|
||||
int fastHash(String string) {
|
||||
var hash = 0xcbf29ce484222325;
|
||||
|
||||
var i = 0;
|
||||
while (i < string.length) {
|
||||
final codeUnit = string.codeUnitAt(i++);
|
||||
hash ^= codeUnit >> 8;
|
||||
hash *= 0x100000001b3;
|
||||
hash ^= codeUnit & 0xFF;
|
||||
hash *= 0x100000001b3;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
13
mobile/lib/utils/hooks/app_settings_update_hook.dart
Normal file
13
mobile/lib/utils/hooks/app_settings_update_hook.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
ValueNotifier<T> useAppSettingsState<T>(AppSettingsEnum<T> key) {
|
||||
final notifier = useState<T>(Store.get(key.storeKey, key.defaultValue));
|
||||
|
||||
// Listen to changes to the notifier and update app settings
|
||||
useValueChanged(notifier.value, (_, __) => Store.put(key.storeKey, notifier.value));
|
||||
|
||||
return notifier;
|
||||
}
|
||||
26
mobile/lib/utils/hooks/blurhash_hook.dart
Normal file
26
mobile/lib/utils/hooks/blurhash_hook.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:thumbhash/thumbhash.dart' as thumbhash;
|
||||
|
||||
ObjectRef<Uint8List?> useBlurHashRef(Asset? asset) {
|
||||
if (asset?.thumbhash == null) {
|
||||
return useRef(null);
|
||||
}
|
||||
|
||||
final rbga = thumbhash.thumbHashToRGBA(base64Decode(asset!.thumbhash!));
|
||||
|
||||
return useRef(thumbhash.rgbaToBmp(rbga));
|
||||
}
|
||||
|
||||
ObjectRef<Uint8List?> useDriftBlurHashRef(RemoteAsset? asset) {
|
||||
if (asset?.thumbHash == null) {
|
||||
return useRef(null);
|
||||
}
|
||||
|
||||
final rbga = thumbhash.thumbHashToRGBA(base64Decode(asset!.thumbHash!));
|
||||
|
||||
return useRef(thumbhash.rgbaToBmp(rbga));
|
||||
}
|
||||
8
mobile/lib/utils/hooks/crop_controller_hook.dart
Normal file
8
mobile/lib/utils/hooks/crop_controller_hook.dart
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:crop_image/crop_image.dart';
|
||||
import 'dart:ui'; // Import the dart:ui library for Rect
|
||||
|
||||
/// A hook that provides a [CropController] instance.
|
||||
CropController useCropController() {
|
||||
return useMemoized(() => CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1)));
|
||||
}
|
||||
15
mobile/lib/utils/hooks/interval_hook.dart
Normal file
15
mobile/lib/utils/hooks/interval_hook.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
|
||||
void useInterval(Duration delay, VoidCallback callback) {
|
||||
final savedCallback = useRef(callback);
|
||||
savedCallback.value = callback;
|
||||
|
||||
useEffect(() {
|
||||
final timer = Timer.periodic(delay, (_) => savedCallback.value());
|
||||
return timer.cancel;
|
||||
}, [delay]);
|
||||
}
|
||||
36
mobile/lib/utils/hooks/timer_hook.dart
Normal file
36
mobile/lib/utils/hooks/timer_hook.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
RestartableTimer useTimer(Duration duration, void Function() callback) {
|
||||
return use(_TimerHook(duration: duration, callback: callback));
|
||||
}
|
||||
|
||||
class _TimerHook extends Hook<RestartableTimer> {
|
||||
final Duration duration;
|
||||
final void Function() callback;
|
||||
|
||||
const _TimerHook({required this.duration, required this.callback});
|
||||
@override
|
||||
HookState<RestartableTimer, Hook<RestartableTimer>> createState() => _TimerHookState();
|
||||
}
|
||||
|
||||
class _TimerHookState extends HookState<RestartableTimer, _TimerHook> {
|
||||
late RestartableTimer timer;
|
||||
@override
|
||||
void initHook() {
|
||||
super.initHook();
|
||||
timer = RestartableTimer(hook.duration, hook.callback);
|
||||
}
|
||||
|
||||
@override
|
||||
RestartableTimer build(BuildContext context) {
|
||||
return timer;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
61
mobile/lib/utils/http_ssl_cert_override.dart
Normal file
61
mobile/lib/utils/http_ssl_cert_override.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class HttpSSLCertOverride extends HttpOverrides {
|
||||
static final Logger _log = Logger("HttpSSLCertOverride");
|
||||
final bool _allowSelfSignedSSLCert;
|
||||
final String? _serverHost;
|
||||
final SSLClientCertStoreVal? _clientCert;
|
||||
late final SecurityContext? _ctxWithCert;
|
||||
|
||||
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) {
|
||||
if (_clientCert != null) {
|
||||
_ctxWithCert = SecurityContext(withTrustedRoots: true);
|
||||
if (_ctxWithCert != null) {
|
||||
setClientCert(_ctxWithCert, _clientCert);
|
||||
} else {
|
||||
_log.severe("Failed to create security context with client cert!");
|
||||
}
|
||||
} else {
|
||||
_ctxWithCert = null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) {
|
||||
try {
|
||||
_log.info("Setting client certificate");
|
||||
ctx.usePrivateKeyBytes(cert.data, password: cert.password);
|
||||
ctx.useCertificateChainBytes(cert.data, password: cert.password);
|
||||
} catch (e) {
|
||||
_log.severe("Failed to set SSL client cert: $e");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
if (context != null) {
|
||||
if (_clientCert != null) {
|
||||
setClientCert(context, _clientCert);
|
||||
}
|
||||
} else {
|
||||
context = _ctxWithCert;
|
||||
}
|
||||
|
||||
return super.createHttpClient(context)
|
||||
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||
if (_allowSelfSignedSSLCert) {
|
||||
// Conduct server host checks if user is logged in to avoid making
|
||||
// insecure SSL connections to services that are not the immich server.
|
||||
if (_serverHost == null || _serverHost.contains(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_log.severe("Invalid SSL certificate for $host:$port");
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
42
mobile/lib/utils/http_ssl_options.dart
Normal file
42
mobile/lib/utils/http_ssl_options.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class HttpSSLOptions {
|
||||
static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions');
|
||||
|
||||
static void apply({bool applyNative = true}) {
|
||||
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
||||
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
||||
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
|
||||
}
|
||||
|
||||
static void applyFromSettings(bool newValue) {
|
||||
_apply(newValue);
|
||||
}
|
||||
|
||||
static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
|
||||
String? serverHost;
|
||||
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
|
||||
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
||||
}
|
||||
|
||||
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
|
||||
|
||||
HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
|
||||
|
||||
if (applyNative && Platform.isAndroid) {
|
||||
_channel
|
||||
.invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password])
|
||||
.onError<PlatformException>((e, _) {
|
||||
final log = Logger("HttpSSLOptions");
|
||||
log.severe('Failed to set SSL options', e.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
65
mobile/lib/utils/image_url_builder.dart
Normal file
65
mobile/lib/utils/image_url_builder.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
String getThumbnailUrl(final Asset asset, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
|
||||
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKey(final Asset asset, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
|
||||
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, asset.thumbhash!, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKeyForRemoteId(
|
||||
final String id,
|
||||
final String thumbhash, {
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
}) {
|
||||
if (type == AssetMediaSize.thumbnail) {
|
||||
return 'thumbnail-image-$id-$thumbhash';
|
||||
} else {
|
||||
return '${id}_${thumbhash}_previewStage';
|
||||
}
|
||||
}
|
||||
|
||||
String getAlbumThumbnailUrl(final Album album, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return getThumbnailCacheKeyForRemoteId(
|
||||
album.thumbnail.value!.remoteId!,
|
||||
album.thumbnail.value!.thumbhash!,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
String getOriginalUrlForRemoteId(final String id, {bool edited = true}) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original?edited=$edited';
|
||||
}
|
||||
|
||||
String getThumbnailUrlForRemoteId(
|
||||
final String id, {
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
bool edited = true,
|
||||
String? thumbhash,
|
||||
}) {
|
||||
final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
|
||||
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
|
||||
}
|
||||
|
||||
String getPlaybackUrlForRemoteId(final String id) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
|
||||
}
|
||||
|
||||
String getFaceThumbnailUrl(final String personId) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail';
|
||||
}
|
||||
64
mobile/lib/utils/immich_loading_overlay.dart
Normal file
64
mobile/lib/utils/immich_loading_overlay.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
|
||||
final _loadingEntry = OverlayEntry(
|
||||
builder: (context) => SizedBox.square(
|
||||
dimension: double.infinity,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
|
||||
child: const Center(
|
||||
child: DelayedLoadingIndicator(delay: Duration(seconds: 1), fadeInDuration: Duration(milliseconds: 400)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
ValueNotifier<bool> useProcessingOverlay() {
|
||||
return use(const _LoadingOverlay());
|
||||
}
|
||||
|
||||
class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
|
||||
const _LoadingOverlay();
|
||||
|
||||
@override
|
||||
_LoadingOverlayState createState() => _LoadingOverlayState();
|
||||
}
|
||||
|
||||
class _LoadingOverlayState extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
|
||||
late final _isLoading = ValueNotifier(false)..addListener(_listener);
|
||||
OverlayEntry? _loadingOverlay;
|
||||
|
||||
void _listener() {
|
||||
setState(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_isLoading.value) {
|
||||
_loadingOverlay?.remove();
|
||||
_loadingOverlay = _loadingEntry;
|
||||
Overlay.of(context).insert(_loadingEntry);
|
||||
} else {
|
||||
_loadingOverlay?.remove();
|
||||
_loadingOverlay = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
ValueNotifier<bool> build(BuildContext context) {
|
||||
return _isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isLoading.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Object? get debugValue => _isLoading.value;
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useProcessingOverlay<>';
|
||||
}
|
||||
95
mobile/lib/utils/isolate.dart
Normal file
95
mobile/lib/utils/isolate.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
import 'package:immich_mobile/wm_executor.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
class InvalidIsolateUsageException implements Exception {
|
||||
const InvalidIsolateUsageException();
|
||||
|
||||
@override
|
||||
String toString() => "IsolateHelper should only be used from the root isolate";
|
||||
}
|
||||
|
||||
// !! Should be used only from the root isolate
|
||||
Cancelable<T?> runInIsolateGentle<T>({
|
||||
required Future<T> Function(ProviderContainer ref) computation,
|
||||
String? debugLabel,
|
||||
}) {
|
||||
final token = RootIsolateToken.instance;
|
||||
if (token == null) {
|
||||
throw const InvalidIsolateUsageException();
|
||||
}
|
||||
|
||||
return workerManagerPatch.executeGentle((cancelledChecker) async {
|
||||
T? result;
|
||||
await runZonedGuarded(
|
||||
() async {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDb, shouldBufferLogs: false, listenStoreUpdates: false);
|
||||
final ref = ProviderContainer(
|
||||
overrides: [
|
||||
// TODO: Remove once isar is removed
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
cancellationProvider.overrideWithValue(cancelledChecker),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
);
|
||||
|
||||
Logger log = Logger("IsolateLogger");
|
||||
|
||||
try {
|
||||
HttpSSLOptions.apply(applyNative: false);
|
||||
result = await computation(ref);
|
||||
} on CanceledError {
|
||||
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");
|
||||
} catch (error, stack) {
|
||||
log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack);
|
||||
} finally {
|
||||
try {
|
||||
ref.dispose();
|
||||
|
||||
await Store.dispose();
|
||||
await LogService.I.dispose();
|
||||
await logDb.close();
|
||||
await drift.close();
|
||||
|
||||
// Close Isar safely
|
||||
try {
|
||||
if (isar.isOpen) {
|
||||
await isar.close();
|
||||
}
|
||||
} catch (e) {
|
||||
dPrint(() => "Error closing Isar: $e");
|
||||
}
|
||||
} catch (error, stack) {
|
||||
dPrint(() => "Error closing resources in isolate: $error, $stack");
|
||||
} finally {
|
||||
ref.dispose();
|
||||
// Delay to ensure all resources are released
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
},
|
||||
(error, stack) {
|
||||
dPrint(() => "Error in isolate $debugLabel zone: $error, $stack");
|
||||
},
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
42
mobile/lib/utils/licenses.dart
Normal file
42
mobile/lib/utils/licenses.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const nonPubLicenses = {
|
||||
'aves': '''
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020, Thibault Deckers
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
''',
|
||||
'photo_view': '''
|
||||
Copyright 2024 Renan C. Araújo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
''',
|
||||
};
|
||||
135
mobile/lib/utils/map_utils.dart
Normal file
135
mobile/lib/utils/map_utils.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapUtils {
|
||||
const MapUtils._();
|
||||
|
||||
static final Logger _log = Logger("MapUtils");
|
||||
static const defaultSourceId = 'asset-map-markers';
|
||||
static const defaultHeatMapLayerId = 'asset-heatmap-layer';
|
||||
|
||||
static const defaultHeatMapLayerProperties = HeatmapLayerProperties(
|
||||
heatmapColor: [
|
||||
Expressions.interpolate,
|
||||
["linear"],
|
||||
["heatmap-density"],
|
||||
0.0,
|
||||
"rgba(103,58,183,0.0)",
|
||||
0.3,
|
||||
"rgb(103,58,183)",
|
||||
0.5,
|
||||
"rgb(33,149,243)",
|
||||
0.7,
|
||||
"rgb(76,175,79)",
|
||||
0.95,
|
||||
"rgb(255,235,59)",
|
||||
1.0,
|
||||
"rgb(255,86,34)",
|
||||
],
|
||||
heatmapIntensity: [
|
||||
Expressions.interpolate, ["linear"], //
|
||||
[Expressions.zoom],
|
||||
0, 0.5,
|
||||
9, 2,
|
||||
],
|
||||
heatmapRadius: [
|
||||
Expressions.interpolate, ["linear"], //
|
||||
[Expressions.zoom],
|
||||
0, 4,
|
||||
4, 8,
|
||||
9, 16,
|
||||
],
|
||||
heatmapOpacity: 0.7,
|
||||
);
|
||||
|
||||
static Map<String, dynamic> _addFeature(MapMarker marker) => {
|
||||
'type': 'Feature',
|
||||
'id': marker.assetRemoteId,
|
||||
'geometry': {
|
||||
'type': 'Point',
|
||||
'coordinates': [marker.latLng.longitude, marker.latLng.latitude],
|
||||
},
|
||||
};
|
||||
|
||||
static Map<String, dynamic> generateGeoJsonForMarkers(List<MapMarker> markers) => {
|
||||
'type': 'FeatureCollection',
|
||||
'features': markers.map(_addFeature).toList(),
|
||||
};
|
||||
|
||||
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation({
|
||||
required BuildContext context,
|
||||
bool silent = false,
|
||||
}) async {
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled && !silent) {
|
||||
unawaited(showDialog(context: context, builder: (context) => _LocationServiceDisabledDialog()));
|
||||
return (null, LocationPermission.deniedForever);
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
bool shouldRequestPermission = false;
|
||||
|
||||
if (permission == LocationPermission.denied && !silent) {
|
||||
shouldRequestPermission = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => _LocationPermissionDisabledDialog(),
|
||||
);
|
||||
if (shouldRequestPermission) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
|
||||
// Open app settings only if you did not request for permission before
|
||||
if (permission == LocationPermission.deniedForever && !shouldRequestPermission && !silent) {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
return (null, LocationPermission.deniedForever);
|
||||
}
|
||||
|
||||
Position currentUserLocation = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 0,
|
||||
timeLimit: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return (currentUserLocation, null);
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot get user's current location", error, stack);
|
||||
return (null, LocationPermission.unableToDetermine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LocationServiceDisabledDialog extends ConfirmDialog {
|
||||
_LocationServiceDisabledDialog()
|
||||
: super(
|
||||
title: 'map_location_service_disabled_title'.tr(),
|
||||
content: 'map_location_service_disabled_content'.tr(),
|
||||
cancel: 'cancel'.tr(),
|
||||
ok: 'yes'.tr(),
|
||||
onOk: () async {
|
||||
await Geolocator.openLocationSettings();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _LocationPermissionDisabledDialog extends ConfirmDialog {
|
||||
_LocationPermissionDisabledDialog()
|
||||
: super(
|
||||
title: 'map_no_location_permission_title'.tr(),
|
||||
content: 'map_no_location_permission_content'.tr(),
|
||||
cancel: 'cancel'.tr(),
|
||||
ok: 'yes'.tr(),
|
||||
onOk: () {},
|
||||
);
|
||||
}
|
||||
389
mobile/lib/utils/migration.dart
Normal file
389
mobile/lib/utils/migration.dart
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart' as isar_backup_album;
|
||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 20;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
final int version = Store.get(StoreKey.version, targetVersion);
|
||||
if (version < 9) {
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
final value = await db.storeValues.get(StoreKey.currentUser.id);
|
||||
if (value != null) {
|
||||
final id = value.intValue;
|
||||
if (id != null) {
|
||||
await db.writeTxn(() async {
|
||||
final user = await db.users.get(id);
|
||||
await db.storeValues.put(StoreValue(StoreKey.currentUser.id, strValue: user?.id));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 10) {
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
await _migrateDeviceAsset(db);
|
||||
}
|
||||
|
||||
if (version < 13) {
|
||||
await Store.put(StoreKey.photoManagerCustomFilter, true);
|
||||
}
|
||||
|
||||
// This means that the SQLite DB is just created and has no version
|
||||
if (version < 14 || !hasVersion) {
|
||||
await migrateStoreToSqlite(db, drift);
|
||||
await Store.populateCache();
|
||||
}
|
||||
|
||||
final syncStreamRepository = SyncStreamRepository(drift);
|
||||
await handleBetaMigration(version, await _isNewInstallation(db, drift), syncStreamRepository);
|
||||
|
||||
if (version < 17 && Store.isBetaTimelineEnabled) {
|
||||
final delay = Store.get(StoreKey.backupTriggerDelay, AppSettingsEnum.backupTriggerDelay.defaultValue);
|
||||
if (delay >= 1000) {
|
||||
await Store.put(StoreKey.backupTriggerDelay, (delay / 1000).toInt());
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 18 && Store.isBetaTimelineEnabled) {
|
||||
await syncStreamRepository.reset();
|
||||
await Store.put(StoreKey.shouldResetSync, true);
|
||||
}
|
||||
|
||||
if (version < 19 && Store.isBetaTimelineEnabled) {
|
||||
if (!await _populateLocalAssetTime(drift)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 20 && Store.isBetaTimelineEnabled) {
|
||||
await _syncLocalAlbumIsIosSharedAlbum(drift);
|
||||
}
|
||||
|
||||
if (targetVersion >= 12) {
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
final shouldTruncate = version < 8 || version < targetVersion;
|
||||
|
||||
if (shouldTruncate) {
|
||||
await _migrateTo(db, targetVersion);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleBetaMigration(int version, bool isNewInstallation, SyncStreamRepository syncStreamRepository) async {
|
||||
// Handle migration only for this version
|
||||
// TODO: remove when old timeline is removed
|
||||
final isBeta = Store.tryGet(StoreKey.betaTimeline);
|
||||
final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration);
|
||||
if (version <= 15 && needBetaMigration == null) {
|
||||
// For new installations, no migration needed
|
||||
// For existing installations, only migrate if beta timeline is not enabled (null or false)
|
||||
if (isNewInstallation || isBeta == true) {
|
||||
await Store.put(StoreKey.needBetaMigration, false);
|
||||
await Store.put(StoreKey.betaTimeline, true);
|
||||
} else {
|
||||
await Store.put(StoreKey.needBetaMigration, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (version > 15) {
|
||||
if (isBeta == null || isBeta) {
|
||||
await Store.put(StoreKey.needBetaMigration, false);
|
||||
await Store.put(StoreKey.betaTimeline, true);
|
||||
} else {
|
||||
await Store.put(StoreKey.needBetaMigration, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 16) {
|
||||
await syncStreamRepository.reset();
|
||||
await Store.put(StoreKey.shouldResetSync, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isNewInstallation(Isar db, Drift drift) async {
|
||||
try {
|
||||
final isarUserCount = await db.users.count();
|
||||
if (isarUserCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final isarAssetCount = await db.assets.count();
|
||||
if (isarAssetCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final driftStoreCount = await drift.storeEntity.select().get().then((list) => list.length);
|
||||
if (driftStoreCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final driftAssetCount = await drift.localAssetEntity.select().get().then((list) => list.length);
|
||||
if (driftAssetCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error checking if new installation: $error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateTo(Isar db, int version) async {
|
||||
await Store.delete(StoreKey.assetETag);
|
||||
await db.writeTxn(() async {
|
||||
await db.assets.clear();
|
||||
await db.exifInfos.clear();
|
||||
await db.albums.clear();
|
||||
await db.eTags.clear();
|
||||
await db.users.clear();
|
||||
});
|
||||
await Store.put(StoreKey.version, version);
|
||||
}
|
||||
|
||||
Future<void> _migrateDeviceAsset(Isar db) async {
|
||||
final ids = Platform.isAndroid
|
||||
? (await db.androidDeviceAssets.where().findAll())
|
||||
.map((a) => _DeviceAsset(assetId: a.id.toString(), hash: a.hash))
|
||||
.toList()
|
||||
: (await db.iOSDeviceAssets.where().findAll()).map((i) => _DeviceAsset(assetId: i.id, hash: i.hash)).toList();
|
||||
|
||||
final PermissionState ps = await PhotoManager.requestPermissionExtend();
|
||||
if (!ps.hasAccess) {
|
||||
dPrint(() => "[MIGRATION] Photo library permission not granted. Skipping device asset migration.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<_DeviceAsset> localAssets = [];
|
||||
final List<AssetPathEntity> paths = await PhotoManager.getAssetPathList(onlyAll: true);
|
||||
|
||||
if (paths.isEmpty) {
|
||||
localAssets = (await db.assets.where().anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId)).findAll())
|
||||
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
|
||||
.toList();
|
||||
} else {
|
||||
final AssetPathEntity albumWithAll = paths.first;
|
||||
final int assetCount = await albumWithAll.assetCountAsync;
|
||||
|
||||
final List<AssetEntity> allDeviceAssets = await albumWithAll.getAssetListRange(start: 0, end: assetCount);
|
||||
|
||||
localAssets = allDeviceAssets.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime)).toList();
|
||||
}
|
||||
|
||||
dPrint(() => "[MIGRATION] Device Asset Ids length - ${ids.length}");
|
||||
dPrint(() => "[MIGRATION] Local Asset Ids length - ${localAssets.length}");
|
||||
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||
final List<DeviceAssetEntity> toAdd = [];
|
||||
await diffSortedLists(
|
||||
ids,
|
||||
localAssets,
|
||||
compare: (a, b) => a.assetId.compareTo(b.assetId),
|
||||
both: (deviceAsset, asset) {
|
||||
toAdd.add(
|
||||
DeviceAssetEntity(assetId: deviceAsset.assetId, hash: deviceAsset.hash!, modifiedTime: asset.dateTime!),
|
||||
);
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (deviceAsset) {
|
||||
dPrint(() => '[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}');
|
||||
},
|
||||
onlySecond: (asset) {
|
||||
dPrint(() => '[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}');
|
||||
},
|
||||
);
|
||||
|
||||
dPrint(() => "[MIGRATION] Total number of device assets migrated - ${toAdd.length}");
|
||||
|
||||
await db.writeTxn(() async {
|
||||
await db.deviceAssetEntitys.putAll(toAdd);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _populateLocalAssetTime(Drift db) async {
|
||||
try {
|
||||
final nativeApi = NativeSyncApi();
|
||||
final albums = await nativeApi.getAlbums();
|
||||
for (final album in albums) {
|
||||
final assets = await nativeApi.getAssetsForAlbum(album.id);
|
||||
await db.batch((batch) async {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.localAssetEntity,
|
||||
LocalAssetEntityCompanion(
|
||||
longitude: Value(asset.longitude),
|
||||
latitude: Value(asset.latitude),
|
||||
adjustmentTime: Value(tryFromSecondsSinceEpoch(asset.adjustmentTime, isUtc: true)),
|
||||
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
|
||||
),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while populating asset time: $error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
|
||||
try {
|
||||
final nativeApi = NativeSyncApi();
|
||||
final albums = await nativeApi.getAlbums();
|
||||
await db.batch((batch) {
|
||||
for (final album in albums) {
|
||||
batch.update(
|
||||
db.localAlbumEntity,
|
||||
LocalAlbumEntityCompanion(isIosSharedAlbum: Value(album.isCloud)),
|
||||
where: (t) => t.id.equals(album.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
dPrint(() => "[MIGRATION] Successfully updated isIosSharedAlbum for ${albums.length} albums");
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while syncing local album isIosSharedAlbum: $error");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
||||
try {
|
||||
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
|
||||
await drift.batch((batch) {
|
||||
for (final deviceAsset in isarDeviceAssets) {
|
||||
batch.update(
|
||||
drift.localAssetEntity,
|
||||
LocalAssetEntityCompanion(checksum: Value(base64.encode(deviceAsset.hash))),
|
||||
where: (t) => t.id.equals(deviceAsset.assetId),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while migrating device assets to SQLite: $error");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateBackupAlbumsToSqlite(Isar db, Drift drift) async {
|
||||
try {
|
||||
final isarBackupAlbums = await db.backupAlbums.where().findAll();
|
||||
// Recents is a virtual album on Android, and we don't have it with the new sync
|
||||
// If recents is selected previously, select all albums during migration except the excluded ones
|
||||
if (Platform.isAndroid) {
|
||||
final recentAlbum = isarBackupAlbums.firstWhereOrNull((album) => album.id == 'isAll');
|
||||
if (recentAlbum != null) {
|
||||
await drift.localAlbumEntity.update().write(
|
||||
const LocalAlbumEntityCompanion(backupSelection: Value(BackupSelection.selected)),
|
||||
);
|
||||
final excluded = isarBackupAlbums
|
||||
.where((album) => album.selection == isar_backup_album.BackupSelection.exclude)
|
||||
.map((album) => album.id)
|
||||
.toList();
|
||||
await drift.batch((batch) async {
|
||||
for (final id in excluded) {
|
||||
batch.update(
|
||||
drift.localAlbumEntity,
|
||||
const LocalAlbumEntityCompanion(backupSelection: Value(BackupSelection.excluded)),
|
||||
where: (t) => t.id.equals(id),
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await drift.batch((batch) {
|
||||
for (final album in isarBackupAlbums) {
|
||||
batch.update(
|
||||
drift.localAlbumEntity,
|
||||
LocalAlbumEntityCompanion(
|
||||
backupSelection: Value(switch (album.selection) {
|
||||
isar_backup_album.BackupSelection.none => BackupSelection.none,
|
||||
isar_backup_album.BackupSelection.select => BackupSelection.selected,
|
||||
isar_backup_album.BackupSelection.exclude => BackupSelection.excluded,
|
||||
}),
|
||||
),
|
||||
where: (t) => t.id.equals(album.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while migrating backup albums to SQLite: $error");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateStoreToSqlite(Isar db, Drift drift) async {
|
||||
try {
|
||||
final isarStoreValues = await db.storeValues.where().findAll();
|
||||
await drift.batch((batch) {
|
||||
for (final storeValue in isarStoreValues) {
|
||||
final companion = StoreEntityCompanion(
|
||||
id: Value(storeValue.id),
|
||||
stringValue: Value(storeValue.strValue),
|
||||
intValue: Value(storeValue.intValue),
|
||||
);
|
||||
batch.insert(drift.storeEntity, companion, onConflict: DoUpdate((_) => companion));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while migrating store values to SQLite: $error");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateStoreToIsar(Isar db, Drift drift) async {
|
||||
try {
|
||||
final driftStoreValues = await drift.storeEntity
|
||||
.select()
|
||||
.map((entity) => StoreValue(entity.id, intValue: entity.intValue, strValue: entity.stringValue))
|
||||
.get();
|
||||
|
||||
await db.writeTxn(() async {
|
||||
await db.storeValues.putAll(driftStoreValues);
|
||||
});
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while migrating store values to Isar: $error");
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceAsset {
|
||||
final String assetId;
|
||||
final List<int>? hash;
|
||||
final DateTime? dateTime;
|
||||
|
||||
const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
|
||||
}
|
||||
82
mobile/lib/utils/openapi_patching.dart
Normal file
82
mobile/lib/utils/openapi_patching.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:openapi/api.dart';
|
||||
|
||||
dynamic upgradeDto(dynamic value, String targetType) {
|
||||
switch (targetType) {
|
||||
case 'UserPreferencesResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'download.includeEmbeddedVideos', false);
|
||||
addDefault(value, 'folders', FoldersResponse().toJson());
|
||||
addDefault(value, 'memories', MemoriesResponse().toJson());
|
||||
addDefault(value, 'ratings', RatingsResponse().toJson());
|
||||
addDefault(value, 'people', PeopleResponse().toJson());
|
||||
addDefault(value, 'tags', TagsResponse().toJson());
|
||||
addDefault(value, 'sharedLinks', SharedLinksResponse().toJson());
|
||||
addDefault(value, 'cast', CastResponse().toJson());
|
||||
addDefault(value, 'albums', {'defaultAssetOrder': 'desc'});
|
||||
}
|
||||
break;
|
||||
case 'ServerConfigDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'mapLightStyleUrl', 'https://tiles.immich.cloud/v1/style/light.json');
|
||||
addDefault(value, 'mapDarkStyleUrl', 'https://tiles.immich.cloud/v1/style/dark.json');
|
||||
}
|
||||
case 'UserResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
|
||||
}
|
||||
break;
|
||||
case 'AssetResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'visibility', 'timeline');
|
||||
addDefault(value, 'createdAt', DateTime.now().toIso8601String());
|
||||
addDefault(value, 'isEdited', false);
|
||||
}
|
||||
break;
|
||||
case 'UserAdminResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
|
||||
}
|
||||
break;
|
||||
case 'LoginResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'isOnboarded', false);
|
||||
}
|
||||
break;
|
||||
case 'SyncUserV1':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
|
||||
addDefault(value, 'hasProfileImage', false);
|
||||
}
|
||||
case 'SyncAssetV1':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'isEdited', false);
|
||||
}
|
||||
case 'ServerFeaturesDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'ocr', false);
|
||||
}
|
||||
break;
|
||||
case 'MemoriesResponse':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'duration', 5);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
addDefault(dynamic value, String keys, dynamic defaultValue) {
|
||||
// Loop through the keys and assign the default value if the key is not present
|
||||
List<String> keyList = keys.split('.');
|
||||
dynamic current = value;
|
||||
|
||||
for (int i = 0; i < keyList.length - 1; i++) {
|
||||
if (current[keyList[i]] == null) {
|
||||
current[keyList[i]] = {};
|
||||
}
|
||||
current = current[keyList[i]];
|
||||
}
|
||||
|
||||
if (current[keyList.last] == null) {
|
||||
current[keyList.last] = defaultValue;
|
||||
}
|
||||
}
|
||||
54
mobile/lib/utils/people.utils.dart
Normal file
54
mobile/lib/utils/people.utils.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
||||
|
||||
String formatAge(DateTime birthDate, DateTime referenceDate) {
|
||||
int ageInYears = _calculateAge(birthDate, referenceDate);
|
||||
int ageInMonths = _calculateAgeInMonths(birthDate, referenceDate);
|
||||
|
||||
if (ageInMonths <= 11) {
|
||||
return "person_age_months".t(args: {'months': ageInMonths.toString()});
|
||||
} else if (ageInMonths > 12 && ageInMonths <= 23) {
|
||||
return "person_age_year_months".t(args: {'months': (ageInMonths - 12).toString()});
|
||||
} else {
|
||||
return "person_age_years".t(args: {'years': ageInYears.toString()});
|
||||
}
|
||||
}
|
||||
|
||||
int _calculateAge(DateTime birthDate, DateTime referenceDate) {
|
||||
int age = referenceDate.year - birthDate.year;
|
||||
if (referenceDate.month < birthDate.month ||
|
||||
(referenceDate.month == birthDate.month && referenceDate.day < birthDate.day)) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) {
|
||||
return (referenceDate.year - birthDate.year) * 12 +
|
||||
referenceDate.month -
|
||||
birthDate.month -
|
||||
(referenceDate.day < birthDate.day ? 1 : 0);
|
||||
}
|
||||
|
||||
Future<String?> showNameEditModal(BuildContext context, DriftPerson person) {
|
||||
return showDialog<String?>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (BuildContext context) {
|
||||
return DriftPersonNameEditForm(person: person);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<DateTime?> showBirthdayEditModal(BuildContext context, DriftPerson person) {
|
||||
return showDialog<DateTime?>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (BuildContext context) {
|
||||
return DriftPersonBirthdayEditForm(person: person);
|
||||
},
|
||||
);
|
||||
}
|
||||
24
mobile/lib/utils/provider_utils.dart
Normal file
24
mobile/lib/utils/provider_utils.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/album_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/person_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/timeline.repository.dart';
|
||||
|
||||
void invalidateAllApiRepositoryProviders(WidgetRef ref) {
|
||||
ref.invalidate(userApiRepositoryProvider);
|
||||
ref.invalidate(activityApiRepositoryProvider);
|
||||
ref.invalidate(partnerApiRepositoryProvider);
|
||||
ref.invalidate(albumApiRepositoryProvider);
|
||||
ref.invalidate(personApiRepositoryProvider);
|
||||
ref.invalidate(assetApiRepositoryProvider);
|
||||
ref.invalidate(timelineRepositoryProvider);
|
||||
ref.invalidate(searchApiRepositoryProvider);
|
||||
|
||||
// Drift
|
||||
ref.invalidate(driftAlbumApiRepositoryProvider);
|
||||
}
|
||||
143
mobile/lib/utils/selection_handlers.dart
Normal file
143
mobile/lib/utils/selection_handlers.dart
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:immich_mobile/services/share.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/share_dialog.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
void handleShareAssets(WidgetRef ref, BuildContext context, Iterable<Asset> selection) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref.watch(shareServiceProvider).shareAssets(selection.toList(), context).then((bool status) {
|
||||
if (!status) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'image_viewer_page_state_provider_share_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
buildContext.pop();
|
||||
});
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleArchiveAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool? shouldArchive,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
shouldArchive ??= !selection.every((a) => a.isArchived);
|
||||
await ref.read(assetProvider.notifier).toggleArchive(selection, shouldArchive);
|
||||
final message = shouldArchive
|
||||
? 'moved_to_archive'.t(context: context, args: {'count': selection.length})
|
||||
: 'moved_to_library'.t(context: context, args: {'count': selection.length});
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(context: context, msg: message, gravity: toastGravity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleFavoriteAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool? shouldFavorite,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
shouldFavorite ??= !selection.every((a) => a.isFavorite);
|
||||
await ref.watch(assetProvider.notifier).toggleFavorite(selection, shouldFavorite);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final toastMessage = shouldFavorite
|
||||
? 'Added ${selection.length} $assetOrAssets to favorites'
|
||||
: 'Removed ${selection.length} $assetOrAssets from favorites';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(context: context, msg: toastMessage, gravity: toastGravity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEditDateTime(WidgetRef ref, BuildContext context, List<Asset> selection) async {
|
||||
DateTime? initialDate;
|
||||
String? timeZone;
|
||||
Duration? offset;
|
||||
if (selection.length == 1) {
|
||||
final asset = selection.first;
|
||||
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
|
||||
final (dt, oft) = assetWithExif.getTZAdjustedTimeAndOffset();
|
||||
initialDate = dt;
|
||||
offset = oft;
|
||||
timeZone = assetWithExif.exifInfo?.timeZone;
|
||||
}
|
||||
final dateTime = await showDateTimePicker(
|
||||
context: context,
|
||||
initialDateTime: initialDate,
|
||||
initialTZ: timeZone,
|
||||
initialTZOffset: offset,
|
||||
);
|
||||
|
||||
if (dateTime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime);
|
||||
}
|
||||
|
||||
Future<void> handleEditLocation(WidgetRef ref, BuildContext context, List<Asset> selection) async {
|
||||
LatLng? initialLatLng;
|
||||
if (selection.length == 1) {
|
||||
final asset = selection.first;
|
||||
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
|
||||
if (assetWithExif.exifInfo?.latitude != null && assetWithExif.exifInfo?.longitude != null) {
|
||||
initialLatLng = LatLng(assetWithExif.exifInfo!.latitude!, assetWithExif.exifInfo!.longitude!);
|
||||
}
|
||||
}
|
||||
|
||||
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
|
||||
|
||||
if (location == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
|
||||
}
|
||||
|
||||
Future<void> handleSetAssetsVisibility(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
AssetVisibilityEnum visibility,
|
||||
List<Asset> selection,
|
||||
) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref.watch(assetProvider.notifier).setLockedView(selection, visibility);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final toastMessage = visibility == AssetVisibilityEnum.locked
|
||||
? 'Added ${selection.length} $assetOrAssets to locked folder'
|
||||
: 'Removed ${selection.length} $assetOrAssets from locked folder';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(context: context, msg: toastMessage, gravity: ToastGravity.BOTTOM);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
mobile/lib/utils/semver.dart
Normal file
87
mobile/lib/utils/semver.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
enum SemVerType { major, minor, patch }
|
||||
|
||||
class SemVer {
|
||||
final int major;
|
||||
final int minor;
|
||||
final int patch;
|
||||
|
||||
const SemVer({required this.major, required this.minor, required this.patch});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$major.$minor.$patch';
|
||||
}
|
||||
|
||||
SemVer copyWith({int? major, int? minor, int? patch}) {
|
||||
return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch);
|
||||
}
|
||||
|
||||
factory SemVer.fromString(String version) {
|
||||
if (version.toLowerCase().startsWith("v")) {
|
||||
version = version.substring(1);
|
||||
}
|
||||
|
||||
final parts = version.split("-")[0].split('.');
|
||||
if (parts.length != 3) {
|
||||
throw FormatException('Invalid semantic version string: $version');
|
||||
}
|
||||
|
||||
try {
|
||||
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
|
||||
} catch (e) {
|
||||
throw FormatException('Invalid semantic version string: $version');
|
||||
}
|
||||
}
|
||||
|
||||
bool operator >(SemVer other) {
|
||||
if (major != other.major) {
|
||||
return major > other.major;
|
||||
}
|
||||
if (minor != other.minor) {
|
||||
return minor > other.minor;
|
||||
}
|
||||
return patch > other.patch;
|
||||
}
|
||||
|
||||
bool operator <(SemVer other) {
|
||||
if (major != other.major) {
|
||||
return major < other.major;
|
||||
}
|
||||
if (minor != other.minor) {
|
||||
return minor < other.minor;
|
||||
}
|
||||
return patch < other.patch;
|
||||
}
|
||||
|
||||
bool operator >=(SemVer other) {
|
||||
return this > other || this == other;
|
||||
}
|
||||
|
||||
bool operator <=(SemVer other) {
|
||||
return this < other || this == other;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
|
||||
}
|
||||
|
||||
SemVerType? differenceType(SemVer other) {
|
||||
if (major != other.major) {
|
||||
return SemVerType.major;
|
||||
}
|
||||
if (minor != other.minor) {
|
||||
return SemVerType.minor;
|
||||
}
|
||||
if (patch != other.patch) {
|
||||
return SemVerType.patch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
|
||||
}
|
||||
7
mobile/lib/utils/string_helper.dart
Normal file
7
mobile/lib/utils/string_helper.dart
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
extension StringExtension on String {
|
||||
String capitalizeFirstLetter() {
|
||||
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
|
||||
}
|
||||
}
|
||||
|
||||
String s(num count) => (count == 1 ? '' : 's');
|
||||
51
mobile/lib/utils/throttle.dart
Normal file
51
mobile/lib/utils/throttle.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Throttles function calls with the [interval] provided.
|
||||
/// Also make sures to call the last Action after the elapsed interval
|
||||
class Throttler {
|
||||
final Duration interval;
|
||||
DateTime? _lastActionTime;
|
||||
|
||||
Throttler({required this.interval});
|
||||
|
||||
T? run<T>(T Function() action) {
|
||||
if (_lastActionTime == null || (DateTime.now().difference(_lastActionTime!) > interval)) {
|
||||
final response = action();
|
||||
_lastActionTime = DateTime.now();
|
||||
return response;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_lastActionTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [Throttler] that will be disposed automatically. If no [interval] is provided, a
|
||||
/// default interval of 300ms is used to throttle the function calls
|
||||
Throttler useThrottler({Duration interval = const Duration(milliseconds: 300), List<Object?>? keys}) =>
|
||||
use(_ThrottleHook(interval: interval, keys: keys));
|
||||
|
||||
class _ThrottleHook extends Hook<Throttler> {
|
||||
const _ThrottleHook({required this.interval, super.keys});
|
||||
|
||||
final Duration interval;
|
||||
|
||||
@override
|
||||
HookState<Throttler, Hook<Throttler>> createState() => _ThrottlerHookState();
|
||||
}
|
||||
|
||||
class _ThrottlerHookState extends HookState<Throttler, _ThrottleHook> {
|
||||
late final throttler = Throttler(interval: hook.interval);
|
||||
|
||||
@override
|
||||
Throttler build(_) => throttler;
|
||||
|
||||
@override
|
||||
void dispose() => throttler.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useThrottler';
|
||||
}
|
||||
49
mobile/lib/utils/thumbnail_utils.dart
Normal file
49
mobile/lib/utils/thumbnail_utils.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
String getAltText(ExifInfo? exifInfo, DateTime fileCreatedAt, AssetType type, List<String> peopleNames) {
|
||||
if (exifInfo?.description != null && exifInfo!.description!.isNotEmpty) {
|
||||
return exifInfo.description!;
|
||||
}
|
||||
final (template, args) = getAltTextTemplate(exifInfo, fileCreatedAt, type, peopleNames);
|
||||
return template.t(args: args);
|
||||
}
|
||||
|
||||
(String, Map<String, String>) getAltTextTemplate(
|
||||
ExifInfo? exifInfo,
|
||||
DateTime fileCreatedAt,
|
||||
AssetType type,
|
||||
List<String> peopleNames,
|
||||
) {
|
||||
final isVideo = type == AssetType.video;
|
||||
final hasLocation = exifInfo?.city != null && exifInfo?.country != null;
|
||||
final date = DateFormat.yMMMMd().format(fileCreatedAt);
|
||||
final args = {
|
||||
"isVideo": isVideo.toString(),
|
||||
"date": date,
|
||||
"city": exifInfo?.city ?? "",
|
||||
"country": exifInfo?.country ?? "",
|
||||
"person1": peopleNames.elementAtOrNull(0) ?? "",
|
||||
"person2": peopleNames.elementAtOrNull(1) ?? "",
|
||||
"person3": peopleNames.elementAtOrNull(2) ?? "",
|
||||
"additionalCount": (peopleNames.length - 3).toString(),
|
||||
};
|
||||
final template = hasLocation
|
||||
? (switch (peopleNames.length) {
|
||||
0 => "image_alt_text_date_place",
|
||||
1 => "image_alt_text_date_place_1_person",
|
||||
2 => "image_alt_text_date_place_2_people",
|
||||
3 => "image_alt_text_date_place_3_people",
|
||||
_ => "image_alt_text_date_place_4_or_more_people",
|
||||
})
|
||||
: (switch (peopleNames.length) {
|
||||
0 => "image_alt_text_date",
|
||||
1 => "image_alt_text_date_1_person",
|
||||
2 => "image_alt_text_date_2_people",
|
||||
3 => "image_alt_text_date_3_people",
|
||||
_ => "image_alt_text_date_4_or_more_people",
|
||||
});
|
||||
return (template, args);
|
||||
}
|
||||
35
mobile/lib/utils/timezone.dart
Normal file
35
mobile/lib/utils/timezone.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:timezone/timezone.dart';
|
||||
|
||||
/// Applies timezone conversion to a DateTime using EXIF timezone information.
|
||||
///
|
||||
/// This function handles two timezone formats:
|
||||
/// 1. Named timezone locations (e.g., "Asia/Hong_Kong")
|
||||
/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00")
|
||||
///
|
||||
/// Returns a tuple of (adjusted DateTime, timezone offset Duration)
|
||||
(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) {
|
||||
DateTime dt = dateTime.toUtc();
|
||||
|
||||
if (timeZone == null) {
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get timezone location from database
|
||||
final location = getLocation(timeZone);
|
||||
dt = TZDateTime.from(dt, location);
|
||||
return (dt, dt.timeZoneOffset);
|
||||
} on LocationNotFoundException {
|
||||
// Handle UTC offset format (e.g., "UTC+08:00")
|
||||
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
|
||||
final m = re.firstMatch(timeZone);
|
||||
if (m != null) {
|
||||
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
|
||||
dt = dt.add(duration);
|
||||
return (dt, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// If timezone is invalid, return UTC
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
182
mobile/lib/utils/upload_speed_calculator.dart
Normal file
182
mobile/lib/utils/upload_speed_calculator.dart
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/// A class to calculate upload speed based on progress updates.
|
||||
///
|
||||
/// Tracks bytes transferred over time and calculates average speed
|
||||
/// using a sliding window approach to smooth out fluctuations.
|
||||
class UploadSpeedCalculator {
|
||||
/// Creates an UploadSpeedCalculator with the given window size.
|
||||
///
|
||||
/// [windowSize] determines how many recent samples to use for
|
||||
/// calculating the average speed. Default is 5 samples.
|
||||
UploadSpeedCalculator({this.windowSize = 5});
|
||||
|
||||
/// The number of samples to keep in the sliding window.
|
||||
final int windowSize;
|
||||
|
||||
/// List of recent speed samples (bytes per second).
|
||||
final List<double> _speedSamples = [];
|
||||
|
||||
/// The timestamp of the last progress update.
|
||||
DateTime? _lastUpdateTime;
|
||||
|
||||
/// The bytes transferred at the last progress update.
|
||||
int _lastBytes = 0;
|
||||
|
||||
/// The total file size being uploaded.
|
||||
int _totalBytes = 0;
|
||||
|
||||
/// Resets the calculator for a new upload.
|
||||
void reset() {
|
||||
_speedSamples.clear();
|
||||
_lastUpdateTime = null;
|
||||
_lastBytes = 0;
|
||||
_totalBytes = 0;
|
||||
}
|
||||
|
||||
/// Updates the calculator with the current progress.
|
||||
///
|
||||
/// [currentBytes] is the number of bytes transferred so far.
|
||||
/// [totalBytes] is the total size of the file being uploaded.
|
||||
///
|
||||
/// Returns the calculated speed in MB/s, or -1 if not enough data.
|
||||
double update(int currentBytes, int totalBytes) {
|
||||
final now = DateTime.now();
|
||||
_totalBytes = totalBytes;
|
||||
|
||||
if (_lastUpdateTime == null) {
|
||||
_lastUpdateTime = now;
|
||||
_lastBytes = currentBytes;
|
||||
return -1;
|
||||
}
|
||||
|
||||
final elapsed = now.difference(_lastUpdateTime!);
|
||||
|
||||
// Only calculate if at least 100ms has passed to avoid division by very small numbers
|
||||
if (elapsed.inMilliseconds < 100) {
|
||||
return _currentSpeed;
|
||||
}
|
||||
|
||||
final bytesTransferred = currentBytes - _lastBytes;
|
||||
final elapsedSeconds = elapsed.inMilliseconds / 1000.0;
|
||||
|
||||
// Calculate bytes per second, then convert to MB/s
|
||||
final bytesPerSecond = bytesTransferred / elapsedSeconds;
|
||||
final mbPerSecond = bytesPerSecond / (1024 * 1024);
|
||||
|
||||
// Add to sliding window
|
||||
_speedSamples.add(mbPerSecond);
|
||||
if (_speedSamples.length > windowSize) {
|
||||
_speedSamples.removeAt(0);
|
||||
}
|
||||
|
||||
_lastUpdateTime = now;
|
||||
_lastBytes = currentBytes;
|
||||
|
||||
return _currentSpeed;
|
||||
}
|
||||
|
||||
/// Returns the current calculated speed in MB/s.
|
||||
///
|
||||
/// Returns -1 if no valid speed has been calculated yet.
|
||||
double get _currentSpeed {
|
||||
if (_speedSamples.isEmpty) {
|
||||
return -1;
|
||||
}
|
||||
// Calculate average of all samples in the window
|
||||
final sum = _speedSamples.fold(0.0, (prev, speed) => prev + speed);
|
||||
return sum / _speedSamples.length;
|
||||
}
|
||||
|
||||
/// Returns the current speed in MB/s, or -1 if not available.
|
||||
double get speed => _currentSpeed;
|
||||
|
||||
/// Returns a human-readable string representation of the current speed.
|
||||
///
|
||||
/// Returns '-- MB/s' if N/A, otherwise in MB/s or kB/s format.
|
||||
String get speedAsString {
|
||||
final s = _currentSpeed;
|
||||
return switch (s) {
|
||||
<= 0 => '-- MB/s',
|
||||
>= 1 => '${s.round()} MB/s',
|
||||
_ => '${(s * 1000).round()} kB/s',
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the estimated time remaining as a Duration.
|
||||
///
|
||||
/// Returns Duration with negative seconds if not calculable.
|
||||
Duration get timeRemaining {
|
||||
final s = _currentSpeed;
|
||||
if (s <= 0 || _totalBytes <= 0 || _lastBytes >= _totalBytes) {
|
||||
return const Duration(seconds: -1);
|
||||
}
|
||||
|
||||
final remainingBytes = _totalBytes - _lastBytes;
|
||||
final bytesPerSecond = s * 1024 * 1024;
|
||||
final secondsRemaining = remainingBytes / bytesPerSecond;
|
||||
|
||||
return Duration(seconds: secondsRemaining.round());
|
||||
}
|
||||
|
||||
/// Returns a human-readable string representation of time remaining.
|
||||
///
|
||||
/// Returns '--:--' if N/A, otherwise HH:MM:SS or MM:SS format.
|
||||
String get timeRemainingAsString {
|
||||
final remaining = timeRemaining;
|
||||
return switch (remaining.inSeconds) {
|
||||
<= 0 => '--:--',
|
||||
< 3600 =>
|
||||
'${remaining.inMinutes.toString().padLeft(2, "0")}'
|
||||
':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
|
||||
_ =>
|
||||
'${remaining.inHours}'
|
||||
':${remaining.inMinutes.remainder(60).toString().padLeft(2, "0")}'
|
||||
':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager for tracking upload speeds for multiple concurrent uploads.
|
||||
///
|
||||
/// Each upload is identified by a unique task ID.
|
||||
class UploadSpeedManager {
|
||||
/// Map of task IDs to their speed calculators.
|
||||
final Map<String, UploadSpeedCalculator> _calculators = {};
|
||||
|
||||
/// Gets or creates a speed calculator for the given task ID.
|
||||
UploadSpeedCalculator getCalculator(String taskId) {
|
||||
return _calculators.putIfAbsent(taskId, () => UploadSpeedCalculator());
|
||||
}
|
||||
|
||||
/// Updates progress for a specific task and returns the speed string.
|
||||
///
|
||||
/// [taskId] is the unique identifier for the upload task.
|
||||
/// [currentBytes] is the number of bytes transferred so far.
|
||||
/// [totalBytes] is the total size of the file being uploaded.
|
||||
///
|
||||
/// Returns the human-readable speed string.
|
||||
String updateProgress(String taskId, int currentBytes, int totalBytes) {
|
||||
final calculator = getCalculator(taskId);
|
||||
calculator.update(currentBytes, totalBytes);
|
||||
return calculator.speedAsString;
|
||||
}
|
||||
|
||||
/// Gets the current speed string for a specific task.
|
||||
String getSpeedAsString(String taskId) {
|
||||
return _calculators[taskId]?.speedAsString ?? '-- MB/s';
|
||||
}
|
||||
|
||||
/// Gets the time remaining string for a specific task.
|
||||
String getTimeRemainingAsString(String taskId) {
|
||||
return _calculators[taskId]?.timeRemainingAsString ?? '--:--';
|
||||
}
|
||||
|
||||
/// Removes a task from tracking.
|
||||
void removeTask(String taskId) {
|
||||
_calculators.remove(taskId);
|
||||
}
|
||||
|
||||
/// Clears all tracked tasks.
|
||||
void clear() {
|
||||
_calculators.clear();
|
||||
}
|
||||
}
|
||||
92
mobile/lib/utils/url_helper.dart
Normal file
92
mobile/lib/utils/url_helper.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:punycode/punycode.dart';
|
||||
|
||||
String sanitizeUrl(String url) {
|
||||
// Add schema if none is set
|
||||
final urlWithSchema = url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
|
||||
|
||||
// Remove trailing slash(es)
|
||||
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
||||
}
|
||||
|
||||
String? getServerUrl() {
|
||||
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
|
||||
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
||||
if (serverUri == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Uri.decodeFull(
|
||||
serverUri.hasPort
|
||||
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
|
||||
: "${serverUri.scheme}://${serverUri.host}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
|
||||
///
|
||||
/// This is especially useful for internationalized domain names (IDNs),
|
||||
/// where parts of the URL (typically the host) contain non-ASCII characters.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final encodedUrl = punycodeEncodeUrl('https://bücher.de');
|
||||
/// print(encodedUrl); // Outputs: https://xn--bcher-kva.de
|
||||
/// ```
|
||||
///
|
||||
/// Notes:
|
||||
/// - If the input URL is invalid, an empty string is returned.
|
||||
/// - Only the host part of the URL is converted to Punycode; the scheme,
|
||||
/// path, and port remain unchanged.
|
||||
///
|
||||
String punycodeEncodeUrl(String serverUrl) {
|
||||
final serverUri = Uri.tryParse(serverUrl);
|
||||
if (serverUri == null || serverUri.host.isEmpty) return '';
|
||||
|
||||
final encodedHost = Uri.decodeComponent(serverUri.host)
|
||||
.split('.')
|
||||
.map((segment) {
|
||||
// If segment is already ASCII, then return as it is.
|
||||
if (segment.runes.every((c) => c < 0x80)) return segment;
|
||||
return 'xn--${punycodeEncode(segment)}';
|
||||
})
|
||||
.join('.');
|
||||
|
||||
return serverUri.replace(host: encodedHost).toString();
|
||||
}
|
||||
|
||||
/// Decodes an ASCII-compatible (Punycode) URL back to its original Unicode representation.
|
||||
///
|
||||
/// This method is useful for converting internationalized domain names (IDNs)
|
||||
/// that were previously encoded with Punycode back to their human-readable Unicode form.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final decodedUrl = punycodeDecodeUrl('https://xn--bcher-kva.de');
|
||||
/// print(decodedUrl); // Outputs: https://bücher.de
|
||||
/// ```
|
||||
///
|
||||
/// Notes:
|
||||
/// - If the input URL is invalid the method returns `null`.
|
||||
/// - Only the host part of the URL is decoded. The scheme and port (if any) are preserved.
|
||||
/// - The method assumes that the input URL only contains: scheme, host, port (optional).
|
||||
/// - Query parameters, fragments, and user info are not handled (by design, as per constraints).
|
||||
///
|
||||
String? punycodeDecodeUrl(String? serverUrl) {
|
||||
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
||||
if (serverUri == null || serverUri.host.isEmpty) return null;
|
||||
|
||||
final decodedHost = serverUri.host
|
||||
.split('.')
|
||||
.map((segment) {
|
||||
if (segment.toLowerCase().startsWith('xn--')) {
|
||||
return punycodeDecode(segment.substring(4));
|
||||
}
|
||||
// If segment is not punycode encoded, then return as it is.
|
||||
return segment;
|
||||
})
|
||||
.join('.');
|
||||
|
||||
return Uri.decodeFull(serverUri.replace(host: decodedHost).toString());
|
||||
}
|
||||
15
mobile/lib/utils/user_agent.dart
Normal file
15
mobile/lib/utils/user_agent.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import 'dart:io' show Platform;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
Future<String> getUserAgentString() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
String platform;
|
||||
if (Platform.isAndroid) {
|
||||
platform = 'Android';
|
||||
} else if (Platform.isIOS) {
|
||||
platform = 'iOS';
|
||||
} else {
|
||||
platform = 'Unknown';
|
||||
}
|
||||
return 'Immich_${platform}_${packageInfo.version}';
|
||||
}
|
||||
12
mobile/lib/utils/version_compatibility.dart
Normal file
12
mobile/lib/utils/version_compatibility.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
String? getVersionCompatibilityMessage(int appMajor, int appMinor, int serverMajor, int serverMinor) {
|
||||
if (serverMajor != appMajor) {
|
||||
return 'Your app major version is not compatible with the server!';
|
||||
}
|
||||
|
||||
// Add latest compat info up top
|
||||
if (serverMinor < 106 && appMinor >= 106) {
|
||||
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue