Compare commits
14 Commits
v1.36.2_56
...
v1.37.0_58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a384798779 | ||
|
|
d31eddf32f | ||
|
|
1068c4ad23 | ||
|
|
cbc979263e | ||
|
|
765181bbc0 | ||
|
|
d82dec9773 | ||
|
|
024177515d | ||
|
|
fb3b36a569 | ||
|
|
614743c8f4 | ||
|
|
47f5e4134e | ||
|
|
efa7b3ba54 | ||
|
|
1e9d67ec39 | ||
|
|
80d0ddca9a | ||
|
|
976d347623 |
@@ -52,7 +52,7 @@ android {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 23
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- If you want to read images-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- If you want to read videos-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <!-- If you want to read audio-->
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 56,
|
||||
"android.injected.version.name" => "1.36.1",
|
||||
"android.injected.version.code" => 58,
|
||||
"android.injected.version.name" => "1.37.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
* Show human readable file size in detail view
|
||||
* Fix permission issue on Android 33
|
||||
@@ -0,0 +1,6 @@
|
||||
* Use binary prefixes for data sizes
|
||||
* Fix not able to show device asset on Android 13
|
||||
* Use cached asset info if unchanged instead of downloading all assets
|
||||
* Add in-app logging
|
||||
* Add search mechanism to album selection page
|
||||
* Improve UI
|
||||
@@ -17,7 +17,7 @@
|
||||
"backup_album_selection_page_albums_device": "Albums on device ({})",
|
||||
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
||||
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
|
||||
"backup_album_selection_page_select_albums": "Select Albums",
|
||||
"backup_album_selection_page_select_albums": "Select albums",
|
||||
"backup_album_selection_page_selection_info": "Selection Info",
|
||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||
"backup_all": "All",
|
||||
@@ -120,6 +120,7 @@
|
||||
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
||||
"profile_drawer_settings": "Settings",
|
||||
"profile_drawer_sign_out": "Sign Out",
|
||||
"profile_drawer_app_logs": "Logs",
|
||||
"search_bar_hint": "Search your photos",
|
||||
"search_page_no_objects": "No Objects Info Available",
|
||||
"search_page_no_places": "No Places Info Available",
|
||||
|
||||
BIN
mobile/fonts/Inconsolata-Regular.ttf
Normal file
BIN
mobile/fonts/Inconsolata-Regular.ttf
Normal file
Binary file not shown.
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.36.1"
|
||||
version_number: "1.37.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -4,6 +4,7 @@ const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
|
||||
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
|
||||
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
|
||||
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
|
||||
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
|
||||
|
||||
// Login Info
|
||||
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
|
||||
@@ -29,3 +30,6 @@ const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
|
||||
// Duplicate asset
|
||||
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
|
||||
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
|
||||
|
||||
// In app logger
|
||||
const String immichLoggerBox = "immichInAppLogger"; // Box
|
||||
@@ -16,11 +16,13 @@ import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.d
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/release_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
@@ -31,8 +33,10 @@ void main() async {
|
||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
||||
Hive.registerAdapter(ImmichLoggerMessageAdapter());
|
||||
|
||||
await Future.wait([
|
||||
Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
|
||||
Hive.openBox(userInfoBox),
|
||||
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
|
||||
Hive.openBox(hiveGithubReleaseInfoBox),
|
||||
@@ -58,6 +62,9 @@ void main() async {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Immich Logger Service
|
||||
ImmichLogger().init();
|
||||
|
||||
runApp(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales,
|
||||
|
||||
@@ -21,8 +21,39 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var cardSize = MediaQuery.of(context).size.width / 2 - 18;
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardSize = MediaQuery.of(context).size.width / 2 - 18;
|
||||
buildEmptyThumbnail() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
|
||||
),
|
||||
child: SizedBox(
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
child: const Center(
|
||||
child: Icon(Icons.no_photography),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() {
|
||||
return CachedNetworkImage(
|
||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageUrl: getAlbumThumbnailUrl(
|
||||
album,
|
||||
type: ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
cacheKey: "${album.albumThumbnailAssetId}",
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
@@ -35,19 +66,9 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageUrl:
|
||||
getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
cacheKey: "${album.albumThumbnailAssetId}",
|
||||
),
|
||||
child: album.albumThumbnailAssetId == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
class ExifBottomSheet extends ConsumerWidget {
|
||||
final Asset assetDetail;
|
||||
@@ -162,7 +163,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
),
|
||||
subtitle: exifInfo.exifImageHeight != null
|
||||
? Text(
|
||||
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${exifInfo.fileSizeInByte!}B ",
|
||||
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte!)} ",
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -178,7 +179,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength}mm ISO${exifInfo.iso} ",
|
||||
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -349,7 +349,6 @@ class BackgroundService {
|
||||
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
|
||||
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
|
||||
]);
|
||||
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/login/models/authentication_state.model.da
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@@ -62,11 +63,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
getBackupInfo();
|
||||
}
|
||||
|
||||
final log = Logger('BackupNotifier');
|
||||
final BackupService _backupService;
|
||||
final ServerInfoService _serverInfoService;
|
||||
final AuthenticationState _authState;
|
||||
final BackgroundService _backgroundService;
|
||||
final Ref ref;
|
||||
var isGettingBackupInfo = false;
|
||||
|
||||
///
|
||||
/// UI INTERACTION
|
||||
@@ -171,9 +174,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// Get all album on the device
|
||||
/// Get all selected and excluded album from the user's persistent storage
|
||||
/// If this is the first time performing backup - set the default selected album to be
|
||||
/// the one that has all assets (Recent on Android, Recents on iOS)
|
||||
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
|
||||
///
|
||||
Future<void> _getBackupAlbumsInfo() async {
|
||||
Stopwatch stopwatch = Stopwatch()..start();
|
||||
// Get all albums on the device
|
||||
List<AvailableAlbum> availableAlbums = [];
|
||||
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
|
||||
@@ -181,6 +185,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
type: RequestType.common,
|
||||
);
|
||||
|
||||
log.info('Found ${albums.length} local albums');
|
||||
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
@@ -218,13 +224,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
);
|
||||
|
||||
if (backupAlbumInfo == null) {
|
||||
debugPrint("[ERROR] getting Hive backup album infomation");
|
||||
log.severe(
|
||||
"backupAlbumInfo == null",
|
||||
"Failed to get Hive backup album information",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// First time backup - set isAll album is the default one for backup.
|
||||
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
|
||||
debugPrint("First time backup setup recent album as default");
|
||||
log.info("First time backup; setup 'Recent(s)' album as default");
|
||||
|
||||
// Get album that contains all assets
|
||||
var list = await PhotoManager.getAssetPathList(
|
||||
@@ -286,9 +295,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] Failed to generate album from id $e");
|
||||
} catch (e, stackTrace) {
|
||||
log.severe("Failed to generate album from id", e, stackTrace);
|
||||
}
|
||||
|
||||
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
///
|
||||
@@ -338,7 +349,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
);
|
||||
|
||||
if (allUniqueAssets.isEmpty) {
|
||||
debugPrint("No Asset On Device");
|
||||
log.info("Not found albums or assets on the device to backup");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetsInDatabase: allAssetsInDatabase,
|
||||
@@ -360,25 +371,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
return;
|
||||
}
|
||||
|
||||
///
|
||||
/// Get all necessary information for calculating the available albums,
|
||||
/// which albums are selected or excluded
|
||||
/// and then update the UI according to those information
|
||||
///
|
||||
Future<void> getBackupInfo() async {
|
||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await _getBackupAlbumsInfo();
|
||||
await _updateServerInfo();
|
||||
await _updateBackupAssetCount();
|
||||
if (!isGettingBackupInfo) {
|
||||
isGettingBackupInfo = true;
|
||||
|
||||
var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await _getBackupAlbumsInfo();
|
||||
await _updateServerInfo();
|
||||
await _updateBackupAssetCount();
|
||||
}
|
||||
|
||||
isGettingBackupInfo = false;
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Save user selection of selected albums and excluded albums to
|
||||
/// Hive database
|
||||
///
|
||||
void _updatePersistentAlbumsSelection() {
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||
@@ -398,9 +413,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
);
|
||||
}
|
||||
|
||||
///
|
||||
/// Invoke backup process
|
||||
///
|
||||
Future<void> startBackupProcess() async {
|
||||
assert(state.backupProgress == BackUpProgressEnum.idle);
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||
@@ -412,7 +425,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
await PhotoManager.clearFileCache();
|
||||
|
||||
if (state.allUniqueAssets.isEmpty) {
|
||||
debugPrint("No Asset On Device - Abort Backup Process");
|
||||
log.info("No Asset On Device - Abort Backup Process");
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||
return;
|
||||
}
|
||||
@@ -530,7 +543,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
// User has been logged out return
|
||||
if (accessKey == null || !_authState.isAuthenticated) {
|
||||
debugPrint("[resumeBackup] not authenticated - abort");
|
||||
log.info("[_resumeBackup] not authenticated - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -539,17 +552,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
_authState.deviceInfo.isAutoBackup) {
|
||||
// check if backup is alreayd in process - then return
|
||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||
log.info("[_resumeBackup] Backup is already in progress - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.backupProgress == BackUpProgressEnum.inBackground) {
|
||||
debugPrint("[resumeBackup] Background backup is running - abort");
|
||||
log.info("[_resumeBackup] Background backup is running - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run backup
|
||||
debugPrint("[resumeBackup] Start back up");
|
||||
log.info("[_resumeBackup] Start back up");
|
||||
await startBackupProcess();
|
||||
}
|
||||
|
||||
@@ -565,7 +578,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
|
||||
final bool hasLock = await _backgroundService.acquireLock();
|
||||
if (!hasLock) {
|
||||
debugPrint("WARNING [resumeBackup] failed to acquireLock");
|
||||
log.warning("WARNING [resumeBackup] failed to acquireLock");
|
||||
return;
|
||||
}
|
||||
await Future.wait([
|
||||
@@ -612,7 +625,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
|
||||
result.add(a.copyWith(lastBackup: times[i]));
|
||||
} on StateError {
|
||||
debugPrint("[_updateAlbumBackupTime] failed to find album in state");
|
||||
log.severe(
|
||||
"[_updateAlbumBackupTime] failed to find album in state",
|
||||
"State Error",
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -631,21 +648,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
log.info("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
}
|
||||
try {
|
||||
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
|
||||
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
} catch (error, stackTrace) {
|
||||
log.severe(
|
||||
"[_notifyBackgroundServiceCanRun] failed to close box",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
||||
await Hive.box(backgroundBackupInfoBox).close();
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
} catch (error, stackTrace) {
|
||||
log.severe(
|
||||
"[_notifyBackgroundServiceCanRun] failed to close box",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
_backgroundService.releaseLock();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
||||
title: Column(
|
||||
children: [
|
||||
Text(
|
||||
"${album.name} (${album.assetCountAsync})",
|
||||
album.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Padding(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
@@ -14,10 +15,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
const BackupAlbumSelectionPage({Key? key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final availableAlbums = ref.watch(backupProvider).availableAlbums;
|
||||
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
|
||||
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
final albums = useState<List<AvailableAlbum>>(
|
||||
ref.watch(backupProvider).availableAlbums,
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -28,7 +32,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
buildAlbumSelectionList() {
|
||||
if (availableAlbums.isEmpty) {
|
||||
if (albums.value.isEmpty) {
|
||||
return const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
);
|
||||
@@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
height: 265,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: availableAlbums.length,
|
||||
itemCount: albums.value.length,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemBuilder: ((context, index) {
|
||||
var thumbnailData = availableAlbums[index].thumbnailData;
|
||||
var thumbnailData = albums.value[index].thumbnailData;
|
||||
return Padding(
|
||||
padding: index == 0
|
||||
? const EdgeInsets.only(left: 16.00)
|
||||
: const EdgeInsets.all(0),
|
||||
child: AlbumInfoCard(
|
||||
imageData: thumbnailData,
|
||||
albumInfo: availableAlbums[index],
|
||||
albumInfo: albums.value[index],
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
label: Text(
|
||||
album.name,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
label: Text(
|
||||
album.name,
|
||||
@@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
}).toSet();
|
||||
}
|
||||
|
||||
buildSearchBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
|
||||
child: TextFormField(
|
||||
onChanged: (searchValue) {
|
||||
albums.value = ref
|
||||
.watch(backupProvider)
|
||||
.availableAlbums
|
||||
.where(
|
||||
(album) => album.name
|
||||
.toLowerCase()
|
||||
.contains(searchValue.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
hintText: "Search",
|
||||
hintStyle: TextStyle(
|
||||
color: isDarkTheme ? Colors.white : Colors.grey,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Colors.grey,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
@@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 0, 0, 0)
|
||||
@@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
|
||||
ListTile(
|
||||
title: Text(
|
||||
"backup_album_selection_page_albums_device"
|
||||
.tr(args: [availableAlbums.length.toString()]),
|
||||
"backup_album_selection_page_albums_device".tr(
|
||||
args: [
|
||||
ref.watch(backupProvider).availableAlbums.length.toString()
|
||||
],
|
||||
),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
subtitle: Padding(
|
||||
@@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
@@ -284,6 +329,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
buildSearchBar(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: buildAlbumSelectionList(),
|
||||
|
||||
@@ -10,8 +10,10 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/openapi_extensions.dart';
|
||||
import 'package:immich_mobile/utils/tuple.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
@@ -25,42 +27,31 @@ class AssetService {
|
||||
final ApiService _apiService;
|
||||
final BackupService _backupService;
|
||||
final BackgroundService _backgroundService;
|
||||
final log = Logger('AssetService');
|
||||
|
||||
AssetService(this._apiService, this._backupService, this._backgroundService);
|
||||
|
||||
/// Returns all local, remote assets in that order
|
||||
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
|
||||
final List<Asset> assets = [];
|
||||
/// Returns `null` if the server state did not change, else list of assets
|
||||
Future<List<Asset>?> getRemoteAssets({required bool hasCache}) async {
|
||||
try {
|
||||
// not using `await` here to fetch local & remote assets concurrently
|
||||
final Future<List<AssetResponseDto>?> remoteTask =
|
||||
_apiService.assetApi.getAllAssets();
|
||||
final Iterable<AssetEntity> newLocalAssets;
|
||||
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
|
||||
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
|
||||
if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
final Set<String> existingIds = remoteAssets
|
||||
.where((e) => e.deviceId == deviceId)
|
||||
.map((e) => e.deviceAssetId)
|
||||
.toSet();
|
||||
newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
|
||||
} else {
|
||||
newLocalAssets = localAssets;
|
||||
final Box box = Hive.box(userInfoBox);
|
||||
final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
|
||||
.assetApi
|
||||
.getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null);
|
||||
if (remote == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
|
||||
// the order (first all local, then remote assets) is important!
|
||||
assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
|
||||
} catch (e) {
|
||||
debugPrint("Error [getAllAsset] ${e.toString()}");
|
||||
box.put(assetEtagKey, remote.second);
|
||||
return remote.first.map(Asset.remote).toList(growable: false);
|
||||
} catch (e, stack) {
|
||||
log.severe('Error while getting remote assets', e, stack);
|
||||
return null;
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// if [urgent] is `true`, do not block by waiting on the background service
|
||||
/// to finish running. Returns an empty list instead after a timeout.
|
||||
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
|
||||
/// to finish running. Returns `null` instead after a timeout.
|
||||
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
|
||||
try {
|
||||
final Future<bool> hasAccess = urgent
|
||||
? _backgroundService.hasAccess
|
||||
@@ -71,15 +62,16 @@ class AssetService {
|
||||
}
|
||||
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||
|
||||
return backupAlbumInfo != null
|
||||
? await _backupService
|
||||
.buildUploadCandidates(backupAlbumInfo.deepCopy())
|
||||
: [];
|
||||
if (backupAlbumInfo != null) {
|
||||
return (await _backupService
|
||||
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
|
||||
.map(Asset.local)
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [_getLocalAssets] ${e.toString()}");
|
||||
return [];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Asset?> getAssetById(String assetId) async {
|
||||
|
||||
@@ -32,7 +32,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
snap: false,
|
||||
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(5),
|
||||
),
|
||||
),
|
||||
leading: Builder(
|
||||
builder: (BuildContext context) {
|
||||
|
||||
@@ -2,12 +2,12 @@ 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/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
|
||||
class ProfileDrawer extends HookConsumerWidget {
|
||||
@@ -70,6 +70,30 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
buildAppLogButton() {
|
||||
return ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
leading: SizedBox(
|
||||
height: double.infinity,
|
||||
child: Icon(
|
||||
Icons.assignment_outlined,
|
||||
color: Theme.of(context).textTheme.labelMedium?.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"profile_drawer_app_logs",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(const AppLogRoute());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Drawer(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -80,6 +104,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
children: [
|
||||
const ProfileDrawerHeader(),
|
||||
buildSettingButton(),
|
||||
buildAppLogButton(),
|
||||
buildSignoutButton(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -20,6 +22,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -37,6 +40,8 @@ class HomePage extends HookConsumerWidget {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
|
||||
final tipOneOpacity = useState(0.0);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
@@ -146,6 +151,49 @@ class HomePage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
buildLoadingIndicator() {
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
tipOneOpacity.value = 1;
|
||||
});
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const ImmichLoadingIndicator(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Text(
|
||||
'Building the timeline',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: tipOneOpacity.value,
|
||||
child: const SizedBox(
|
||||
width: 250,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).',
|
||||
textAlign: TextAlign.justify,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
bottom: !multiselectEnabled.state,
|
||||
top: true,
|
||||
@@ -164,15 +212,17 @@ class HomePage extends HookConsumerWidget {
|
||||
top: selectionEnabledHook.value ? 0 : 60,
|
||||
bottom: 0.0,
|
||||
),
|
||||
child: ImmichAssetGrid(
|
||||
renderList: renderList,
|
||||
assetsPerRow:
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
),
|
||||
child: ref.watch(assetProvider).isEmpty
|
||||
? buildLoadingIndicator()
|
||||
: ImmichAssetGrid(
|
||||
renderList: renderList,
|
||||
assetsPerRow: appSettingService
|
||||
.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
),
|
||||
),
|
||||
if (selectionEnabledHook.value)
|
||||
ControlBottomAppBar(
|
||||
|
||||
@@ -101,11 +101,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
}
|
||||
|
||||
Future<bool> logout() async {
|
||||
Hive.box(userInfoBox).delete(accessTokenKey);
|
||||
state = state.copyWith(isAuthenticated: false);
|
||||
_assetCacheService.invalidate();
|
||||
_albumCacheService.invalidate();
|
||||
_sharedAlbumCacheService.invalidate();
|
||||
await Future.wait([
|
||||
Hive.box(userInfoBox).delete(accessTokenKey),
|
||||
Hive.box(userInfoBox).delete(assetEtagKey),
|
||||
_assetCacheService.invalidate(),
|
||||
_albumCacheService.invalidate(),
|
||||
_sharedAlbumCacheService.invalidate(),
|
||||
]);
|
||||
|
||||
// Remove login info from local storage
|
||||
var loginInfo =
|
||||
@@ -115,7 +118,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
loginInfo.password = "";
|
||||
loginInfo.isSaveLogin = false;
|
||||
|
||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
|
||||
await Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
|
||||
savedLoginInfoKey,
|
||||
loginInfo,
|
||||
);
|
||||
|
||||
@@ -83,6 +83,13 @@ class LoginForm extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
populateTestLoginInfo() {
|
||||
usernameController.text = 'testuser@email.com';
|
||||
passwordController.text = 'password';
|
||||
serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
||||
isSaveLoginInfo.value = true;
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
@@ -92,10 +99,13 @@ class LoginForm extends HookConsumerWidget {
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Image(
|
||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
width: 100,
|
||||
filterQuality: FilterQuality.high,
|
||||
GestureDetector(
|
||||
onDoubleTap: () => populateTestLoginInfo(),
|
||||
child: const Image(
|
||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
width: 100,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'IMMICH',
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/ui/login_form.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
const LoginPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const Scaffold(
|
||||
body: LoginForm(),
|
||||
final appVersion = useState('0.0.0');
|
||||
|
||||
getAppInfo() async {
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
appVersion.value = packageInfo.version;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
getAppInfo();
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: const LoginForm(),
|
||||
bottomNavigationBar: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'v${appVersion.value}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: "Inconsolata",
|
||||
),
|
||||
),
|
||||
const Text(' '),
|
||||
GestureDetector(
|
||||
child: Text(
|
||||
'Logs',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: "Inconsolata",
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(const AppLogRoute());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
|
||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
||||
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
|
||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/views/app_log_page.dart';
|
||||
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@@ -80,6 +81,10 @@ part 'router.gr.dart';
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
),
|
||||
AutoRoute(page: SettingsPage, guards: [AuthGuard]),
|
||||
CustomRoute(
|
||||
page: AppLogPage,
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
),
|
||||
],
|
||||
)
|
||||
class AppRouter extends _$AppRouter {
|
||||
|
||||
@@ -142,6 +142,14 @@ class _$AppRouter extends RootStackRouter {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const SettingsPage());
|
||||
},
|
||||
AppLogRoute.name: (routeData) {
|
||||
return CustomPage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const AppLogPage(),
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
opaque: true,
|
||||
barrierDismissible: false);
|
||||
},
|
||||
HomeRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const HomePage());
|
||||
@@ -218,7 +226,8 @@ class _$AppRouter extends RootStackRouter {
|
||||
RouteConfig(FailedBackupStatusRoute.name,
|
||||
path: '/failed-backup-status-page', guards: [authGuard]),
|
||||
RouteConfig(SettingsRoute.name,
|
||||
path: '/settings-page', guards: [authGuard])
|
||||
path: '/settings-page', guards: [authGuard]),
|
||||
RouteConfig(AppLogRoute.name, path: '/app-log-page')
|
||||
];
|
||||
}
|
||||
|
||||
@@ -560,6 +569,14 @@ class SettingsRoute extends PageRouteInfo<void> {
|
||||
static const String name = 'SettingsRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AppLogPage]
|
||||
class AppLogRoute extends PageRouteInfo<void> {
|
||||
const AppLogRoute() : super(AppLogRoute.name, path: '/app-log-page');
|
||||
|
||||
static const String name = 'AppLogRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [HomePage]
|
||||
class HomeRoute extends PageRouteInfo<void> {
|
||||
|
||||
34
mobile/lib/shared/models/immich_logger_message.model.dart
Normal file
34
mobile/lib/shared/models/immich_logger_message.model.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'immich_logger_message.model.g.dart';
|
||||
|
||||
@HiveType(typeId: 3)
|
||||
class ImmichLoggerMessage {
|
||||
@HiveField(0)
|
||||
String message;
|
||||
|
||||
@HiveField(1, defaultValue: "INFO")
|
||||
String level;
|
||||
|
||||
@HiveField(2)
|
||||
DateTime createdAt;
|
||||
|
||||
@HiveField(3)
|
||||
String? context1;
|
||||
|
||||
@HiveField(4)
|
||||
String? context2;
|
||||
|
||||
ImmichLoggerMessage({
|
||||
required this.message,
|
||||
required this.level,
|
||||
required this.createdAt,
|
||||
required this.context1,
|
||||
required this.context2,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
|
||||
}
|
||||
}
|
||||
53
mobile/lib/shared/models/immich_logger_message.model.g.dart
Normal file
53
mobile/lib/shared/models/immich_logger_message.model.g.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'immich_logger_message.model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ImmichLoggerMessageAdapter extends TypeAdapter<ImmichLoggerMessage> {
|
||||
@override
|
||||
final int typeId = 3;
|
||||
|
||||
@override
|
||||
ImmichLoggerMessage read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ImmichLoggerMessage(
|
||||
message: fields[0] as String,
|
||||
level: fields[1] == null ? 'INFO' : fields[1] as String,
|
||||
createdAt: fields[2] as DateTime,
|
||||
context1: fields[3] as String?,
|
||||
context2: fields[4] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ImmichLoggerMessage obj) {
|
||||
writer
|
||||
..writeByte(5)
|
||||
..writeByte(0)
|
||||
..write(obj.message)
|
||||
..writeByte(1)
|
||||
..write(obj.level)
|
||||
..writeByte(2)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(3)
|
||||
..write(obj.context1)
|
||||
..writeByte(4)
|
||||
..write(obj.context2);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ImmichLoggerMessageAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class AssetNotifier extends StateNotifier<List<Asset>> {
|
||||
final AssetService _assetService;
|
||||
final AssetCacheService _assetCacheService;
|
||||
|
||||
final log = Logger('AssetNotifier');
|
||||
final DeviceInfoService _deviceInfoService = DeviceInfoService();
|
||||
bool _getAllAssetInProgress = false;
|
||||
bool _deleteInProgress = false;
|
||||
@@ -33,32 +35,61 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
|
||||
final stopwatch = Stopwatch();
|
||||
try {
|
||||
_getAllAssetInProgress = true;
|
||||
|
||||
final bool isCacheValid = await _assetCacheService.isValid();
|
||||
stopwatch.start();
|
||||
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
|
||||
final remoteTask = _assetService.getRemoteAssets(hasCache: isCacheValid);
|
||||
if (isCacheValid && state.isEmpty) {
|
||||
stopwatch.start();
|
||||
state = await _assetCacheService.get();
|
||||
debugPrint(
|
||||
log.info(
|
||||
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
|
||||
);
|
||||
stopwatch.reset();
|
||||
}
|
||||
|
||||
stopwatch.start();
|
||||
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
|
||||
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
|
||||
int remoteBegin = state.indexWhere((a) => a.isRemote);
|
||||
remoteBegin = remoteBegin == -1 ? state.length : remoteBegin;
|
||||
final List<Asset> currentLocal = state.slice(0, remoteBegin);
|
||||
List<Asset>? newRemote = await remoteTask;
|
||||
List<Asset>? newLocal = await localTask;
|
||||
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
stopwatch.reset();
|
||||
|
||||
state = allAssets;
|
||||
if (newRemote == null &&
|
||||
(newLocal == null || currentLocal.equals(newLocal))) {
|
||||
log.info("state is already up-to-date");
|
||||
return;
|
||||
}
|
||||
newRemote ??= state.slice(remoteBegin);
|
||||
newLocal ??= [];
|
||||
state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote);
|
||||
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
} finally {
|
||||
_getAllAssetInProgress = false;
|
||||
}
|
||||
debugPrint("[getAllAsset] setting new asset state");
|
||||
log.info("setting new asset state");
|
||||
|
||||
stopwatch.start();
|
||||
_cacheState();
|
||||
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
|
||||
stopwatch.reset();
|
||||
_cacheState();
|
||||
log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
List<Asset> _combineLocalAndRemoteAssets({
|
||||
required Iterable<Asset> local,
|
||||
required List<Asset> remote,
|
||||
}) {
|
||||
final List<Asset> assets = [];
|
||||
if (remote.isNotEmpty && local.isNotEmpty) {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
final Set<String> existingIds = remote
|
||||
.where((e) => e.deviceId == deviceId)
|
||||
.map((e) => e.deviceAssetId)
|
||||
.toSet();
|
||||
local = local.where((e) => !existingIds.contains(e.id));
|
||||
}
|
||||
assets.addAll(local);
|
||||
// the order (first all local, then remote assets) is important!
|
||||
assets.addAll(remote);
|
||||
return assets;
|
||||
}
|
||||
|
||||
clearAllAsset() {
|
||||
@@ -124,8 +155,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
|
||||
if (local.isNotEmpty) {
|
||||
try {
|
||||
return await PhotoManager.editor.deleteWithIds(local);
|
||||
} catch (e) {
|
||||
debugPrint("Delete asset from device failed: $e");
|
||||
} catch (e, stack) {
|
||||
log.severe("Failed to delete asset from device", e, stack);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class ReleaseInfoNotifier extends StateNotifier<String> {
|
||||
ReleaseInfoNotifier() : super("");
|
||||
|
||||
final log = Logger('ReleaseInfoNotifier');
|
||||
void checkGithubReleaseInfo() async {
|
||||
final Client client = Client();
|
||||
var box = Hive.box(hiveGithubReleaseInfoBox);
|
||||
@@ -28,9 +29,6 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
|
||||
String latestTagVersion = data["tag_name"];
|
||||
state = latestTagVersion;
|
||||
|
||||
debugPrint("Local release version $localReleaseVersion");
|
||||
debugPrint("Remote release veresion $latestTagVersion");
|
||||
|
||||
if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
|
||||
VersionAnnouncementOverlayController.appLoader.show();
|
||||
return;
|
||||
|
||||
@@ -6,23 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart';
|
||||
|
||||
class WebscoketState {
|
||||
class WebsocketState {
|
||||
final Socket? socket;
|
||||
final bool isConnected;
|
||||
|
||||
WebscoketState({
|
||||
WebsocketState({
|
||||
this.socket,
|
||||
required this.isConnected,
|
||||
});
|
||||
|
||||
WebscoketState copyWith({
|
||||
WebsocketState copyWith({
|
||||
Socket? socket,
|
||||
bool? isConnected,
|
||||
}) {
|
||||
return WebscoketState(
|
||||
return WebsocketState(
|
||||
socket: socket ?? this.socket,
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
);
|
||||
@@ -30,13 +31,13 @@ class WebscoketState {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'WebscoketState(socket: $socket, isConnected: $isConnected)';
|
||||
'WebsocketState(socket: $socket, isConnected: $isConnected)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is WebscoketState &&
|
||||
return other is WebsocketState &&
|
||||
other.socket == socket &&
|
||||
other.isConnected == isConnected;
|
||||
}
|
||||
@@ -45,12 +46,11 @@ class WebscoketState {
|
||||
int get hashCode => socket.hashCode ^ isConnected.hashCode;
|
||||
}
|
||||
|
||||
class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
WebsocketNotifier(this.ref)
|
||||
: super(WebscoketState(socket: null, isConnected: false)) {
|
||||
debugPrint("Init websocket instance");
|
||||
}
|
||||
: super(WebsocketState(socket: null, isConnected: false));
|
||||
|
||||
final log = Logger('WebsocketNotifier');
|
||||
final Ref ref;
|
||||
|
||||
connect() {
|
||||
@@ -60,8 +60,8 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
try {
|
||||
debugPrint("[WEBSOCKET] Attempting to connect to ws");
|
||||
// Configure socket transports must be sepecified
|
||||
debugPrint("Attempting to connect to websocket");
|
||||
// Configure socket transports must be specified
|
||||
Socket socket = io(
|
||||
endpoint.toString().replaceAll('/api', ''),
|
||||
OptionBuilder()
|
||||
@@ -76,18 +76,18 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
);
|
||||
|
||||
socket.onConnect((_) {
|
||||
debugPrint("[WEBSOCKET] Established Websocket Connection");
|
||||
state = WebscoketState(isConnected: true, socket: socket);
|
||||
debugPrint("Established Websocket Connection");
|
||||
state = WebsocketState(isConnected: true, socket: socket);
|
||||
});
|
||||
|
||||
socket.onDisconnect((_) {
|
||||
debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
|
||||
state = WebscoketState(isConnected: false, socket: null);
|
||||
debugPrint("Disconnect to Websocket Connection");
|
||||
state = WebsocketState(isConnected: false, socket: null);
|
||||
});
|
||||
|
||||
socket.on('error', (errorMessage) {
|
||||
debugPrint("Webcoket Error - $errorMessage");
|
||||
state = WebscoketState(isConnected: false, socket: null);
|
||||
log.severe("Websocket Error - $errorMessage");
|
||||
state = WebsocketState(isConnected: false, socket: null);
|
||||
});
|
||||
|
||||
socket.on('on_upload_success', (data) {
|
||||
@@ -105,21 +105,22 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
debugPrint("[WEBSOCKET] Attempting to disconnect");
|
||||
debugPrint("Attempting to disconnect from websocket");
|
||||
|
||||
var socket = state.socket?.disconnect();
|
||||
|
||||
if (socket?.disconnected == true) {
|
||||
state = WebscoketState(isConnected: false, socket: null);
|
||||
state = WebsocketState(isConnected: false, socket: null);
|
||||
}
|
||||
}
|
||||
|
||||
stopListenToEvent(String eventName) {
|
||||
debugPrint("[Websocket] Stop listening to event $eventName");
|
||||
debugPrint("Stop listening to event $eventName");
|
||||
state.socket?.off(eventName);
|
||||
}
|
||||
|
||||
listenUploadEvent() {
|
||||
debugPrint("[Websocket] Start listening to event on_upload_success");
|
||||
debugPrint("Start listening to event on_upload_success");
|
||||
state.socket?.on('on_upload_success', (data) {
|
||||
var jsonString = jsonDecode(data.toString());
|
||||
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
|
||||
@@ -132,6 +133,6 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
||||
}
|
||||
|
||||
final websocketProvider =
|
||||
StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
|
||||
StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
|
||||
return WebsocketNotifier(ref);
|
||||
});
|
||||
|
||||
95
mobile/lib/shared/services/immich_logger.service.dart
Normal file
95
mobile/lib/shared/services/immich_logger.service.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
|
||||
/// The logs are written to a Hive box and onto console, using `debugPrint` method.
|
||||
///
|
||||
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
|
||||
/// in the class.
|
||||
///
|
||||
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
|
||||
/// and generate a csv file.
|
||||
class ImmichLogger {
|
||||
final maxLogEntries = 200;
|
||||
final Box<ImmichLoggerMessage> _box = Hive.box(immichLoggerBox);
|
||||
|
||||
List<ImmichLoggerMessage> get messages =>
|
||||
_box.values.toList().reversed.toList();
|
||||
|
||||
ImmichLogger() {
|
||||
_removeOverflowMessages();
|
||||
}
|
||||
|
||||
init() {
|
||||
Logger.root.level = Level.INFO;
|
||||
Logger.root.onRecord.listen(_writeLogToHiveBox);
|
||||
}
|
||||
|
||||
_removeOverflowMessages() {
|
||||
if (_box.length > maxLogEntries) {
|
||||
var numberOfEntryToBeDeleted = _box.length - maxLogEntries;
|
||||
for (var i = 0; i < numberOfEntryToBeDeleted; i++) {
|
||||
_box.deleteAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_writeLogToHiveBox(LogRecord record) {
|
||||
final Box<ImmichLoggerMessage> box = Hive.box(immichLoggerBox);
|
||||
var formattedMessage = record.message;
|
||||
|
||||
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
|
||||
box.add(
|
||||
ImmichLoggerMessage(
|
||||
message: formattedMessage,
|
||||
level: record.level.name,
|
||||
createdAt: record.time,
|
||||
context1: record.loggerName,
|
||||
context2: record.stackTrace?.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearLogs() {
|
||||
_box.clear();
|
||||
}
|
||||
|
||||
Future<void> shareLogs() async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final dateTime = DateTime.now().toIso8601String();
|
||||
final filePath = '${tempDir.path}/Immich_log_$dateTime.csv';
|
||||
final logFile = await File(filePath).create();
|
||||
final io = logFile.openWrite();
|
||||
try {
|
||||
// Write header
|
||||
io.write("created_at,level,context,message,stacktrace\n");
|
||||
|
||||
// Write messages
|
||||
for (final m in messages) {
|
||||
io.write(
|
||||
'${m.createdAt},${m.level},"${m.context1 ?? ""}","${m.message}","${m.context2 ?? ""}"\n',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await io.flush();
|
||||
await io.close();
|
||||
}
|
||||
|
||||
// Share file
|
||||
await Share.shareFiles(
|
||||
[filePath],
|
||||
subject: "Immich logs $dateTime",
|
||||
sharePositionOrigin: Rect.zero,
|
||||
);
|
||||
|
||||
// Clean up temp file
|
||||
await logFile.delete();
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,12 @@ abstract class JsonCache<T> {
|
||||
}
|
||||
|
||||
Future<void> invalidate() async {
|
||||
final file = await _getCacheFile();
|
||||
await file.delete();
|
||||
try {
|
||||
final file = await _getCacheFile();
|
||||
await file.delete();
|
||||
} on FileSystemException {
|
||||
// file is already deleted
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> putRawData(dynamic data) async {
|
||||
@@ -46,4 +50,4 @@ abstract class JsonCache<T> {
|
||||
|
||||
void put(T data);
|
||||
Future<T> get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ class ImmichLoadingIndicator extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: const CircularProgressIndicator(color: Colors.white),
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
165
mobile/lib/shared/views/app_log_page.dart
Normal file
165
mobile/lib/shared/views/app_log_page.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class AppLogPage extends HookConsumerWidget {
|
||||
const AppLogPage({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final immichLogger = ImmichLogger();
|
||||
final logMessages = useState(immichLogger.messages);
|
||||
|
||||
Widget colorStatusIndicator(Color color) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLeadingIcon(String level) {
|
||||
switch (level) {
|
||||
case "INFO":
|
||||
return colorStatusIndicator(Theme.of(context).primaryColor);
|
||||
case "SEVERE":
|
||||
return colorStatusIndicator(Colors.redAccent);
|
||||
|
||||
case "WARNING":
|
||||
return colorStatusIndicator(Colors.orangeAccent);
|
||||
default:
|
||||
return colorStatusIndicator(Colors.grey);
|
||||
}
|
||||
}
|
||||
|
||||
getTileColor(String level) {
|
||||
switch (level) {
|
||||
case "INFO":
|
||||
return Colors.transparent;
|
||||
case "SEVERE":
|
||||
return Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.redAccent.withOpacity(0.25)
|
||||
: Colors.redAccent.withOpacity(0.075);
|
||||
case "WARNING":
|
||||
return Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.orangeAccent.withOpacity(0.25)
|
||||
: Colors.orangeAccent.withOpacity(0.075);
|
||||
default:
|
||||
return Theme.of(context).primaryColor.withOpacity(0.1);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
"Logs - ${logMessages.value.length}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
),
|
||||
scrolledUnderElevation: 1,
|
||||
elevation: 2,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
semanticLabel: "Clear logs",
|
||||
size: 20.0,
|
||||
),
|
||||
onPressed: () {
|
||||
immichLogger.clearLogs();
|
||||
logMessages.value = [];
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.share_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
semanticLabel: "Share logs",
|
||||
size: 20.0,
|
||||
),
|
||||
onPressed: () {
|
||||
immichLogger.shareLogs();
|
||||
},
|
||||
),
|
||||
],
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
size: 20.0,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: ListView.separated(
|
||||
separatorBuilder: (context, index) {
|
||||
return Divider(
|
||||
height: 0,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white70
|
||||
: Colors.grey[600],
|
||||
);
|
||||
},
|
||||
itemCount: logMessages.value.length,
|
||||
itemBuilder: (context, index) {
|
||||
var logMessage = logMessages.value[index];
|
||||
return ListTile(
|
||||
visualDensity: VisualDensity.compact,
|
||||
dense: true,
|
||||
tileColor: getTileColor(logMessage.level),
|
||||
minLeadingWidth: 10,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "#$index ",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white70
|
||||
: Colors.grey[600],
|
||||
fontSize: 14.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: logMessage.message,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
|
||||
),
|
||||
subtitle: Text(
|
||||
"[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
leading: buildLeadingIcon(logMessage.level),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes < 1000) {
|
||||
return "$bytes B";
|
||||
} else if (bytes < 1000000) {
|
||||
final kb = (bytes / 1000).toStringAsFixed(1);
|
||||
return "$kb kB";
|
||||
} else if (bytes < 1000000000) {
|
||||
final mb = (bytes / 1000000).toStringAsFixed(1);
|
||||
return "$mb MB";
|
||||
} else {
|
||||
final gb = (bytes / 1000000000).toStringAsFixed(1);
|
||||
return "$gb GB";
|
||||
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]}";
|
||||
}
|
||||
|
||||
53
mobile/lib/utils/openapi_extensions.dart
Normal file
53
mobile/lib/utils/openapi_extensions.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
import 'tuple.dart';
|
||||
|
||||
/// Extension methods to retrieve ETag together with the API call
|
||||
extension WithETag on AssetApi {
|
||||
/// Get all AssetEntity belong to the user
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] eTag:
|
||||
/// ETag of data already cached on the client
|
||||
Future<Pair<List<AssetResponseDto>, String?>?> getAllAssetsWithETag({
|
||||
String? eTag,
|
||||
}) async {
|
||||
final response = await getAllAssetsWithHttpInfo(
|
||||
ifNoneMatch: eTag,
|
||||
);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty &&
|
||||
response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
final etag = response.headers[HttpHeaders.etagHeader];
|
||||
final data = (await apiClient.deserializeAsync(
|
||||
responseBody, 'List<AssetResponseDto>') as List)
|
||||
.cast<AssetResponseDto>()
|
||||
.toList();
|
||||
return Pair(data, etag);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the decoded body as UTF-8 if the given headers indicate an 'application/json'
|
||||
/// content type. Otherwise, returns the decoded body as decoded by dart:http package.
|
||||
Future<String> _decodeBodyBytes(Response response) async {
|
||||
final contentType = response.headers['content-type'];
|
||||
return contentType != null &&
|
||||
contentType.toLowerCase().startsWith('application/json')
|
||||
? response.bodyBytes.isEmpty
|
||||
? ''
|
||||
: utf8.decode(response.bodyBytes)
|
||||
: response.body;
|
||||
}
|
||||
8
mobile/lib/utils/tuple.dart
Normal file
8
mobile/lib/utils/tuple.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
/// An immutable pair or 2-tuple
|
||||
/// TODO replace with Record once Dart 2.19 is available
|
||||
class Pair<T1, T2> {
|
||||
final T1 first;
|
||||
final T2 second;
|
||||
|
||||
const Pair(this.first, this.second);
|
||||
}
|
||||
@@ -274,7 +274,7 @@ Name | Type | Description | Notes
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAllAssets**
|
||||
> List<AssetResponseDto> getAllAssets()
|
||||
> List<AssetResponseDto> getAllAssets(ifNoneMatch)
|
||||
|
||||
|
||||
|
||||
@@ -291,9 +291,10 @@ import 'package:openapi/api.dart';
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = AssetApi();
|
||||
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
|
||||
|
||||
try {
|
||||
final result = api_instance.getAllAssets();
|
||||
final result = api_instance.getAllAssets(ifNoneMatch);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getAllAssets: $e\n');
|
||||
@@ -301,7 +302,10 @@ try {
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
|
||||
|
||||
### Return type
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ Name | Type | Description | Notes
|
||||
**mimeType** | **String** | |
|
||||
**duration** | **String** | |
|
||||
**webpPath** | **String** | |
|
||||
**encodedVideoPath** | **String** | |
|
||||
**encodedVideoPath** | **String** | | [optional]
|
||||
**exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional]
|
||||
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
|
||||
**livePhotoVideoId** | **String** | |
|
||||
**livePhotoVideoId** | **String** | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Name | Type | Description | Notes
|
||||
**profileImagePath** | **String** | |
|
||||
**shouldChangePassword** | **bool** | |
|
||||
**isAdmin** | **bool** | |
|
||||
**deletedAt** | [**DateTime**](DateTime.md) | |
|
||||
**deletedAt** | [**DateTime**](DateTime.md) | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@@ -297,7 +297,12 @@ class AssetApi {
|
||||
/// Get all AssetEntity belong to the user
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getAllAssetsWithHttpInfo() async {
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] ifNoneMatch:
|
||||
/// ETag of data already cached on the client
|
||||
Future<Response> getAllAssetsWithHttpInfo({ String? ifNoneMatch, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset';
|
||||
|
||||
@@ -308,6 +313,10 @@ class AssetApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (ifNoneMatch != null) {
|
||||
headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
@@ -325,8 +334,13 @@ class AssetApi {
|
||||
///
|
||||
///
|
||||
/// Get all AssetEntity belong to the user
|
||||
Future<List<AssetResponseDto>?> getAllAssets() async {
|
||||
final response = await getAllAssetsWithHttpInfo();
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] ifNoneMatch:
|
||||
/// ETag of data already cached on the client
|
||||
Future<List<AssetResponseDto>?> getAllAssets({ String? ifNoneMatch, }) async {
|
||||
final response = await getAllAssetsWithHttpInfo( ifNoneMatch: ifNoneMatch, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -43,48 +43,51 @@ class AlbumResponseDto {
|
||||
List<AssetResponseDto> assets;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
|
||||
other.assetCount == assetCount &&
|
||||
other.id == id &&
|
||||
other.ownerId == ownerId &&
|
||||
other.albumName == albumName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.shared == shared &&
|
||||
other.sharedUsers == sharedUsers &&
|
||||
other.assets == assets;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AlbumResponseDto &&
|
||||
other.assetCount == assetCount &&
|
||||
other.id == id &&
|
||||
other.ownerId == ownerId &&
|
||||
other.albumName == albumName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.shared == shared &&
|
||||
other.sharedUsers == sharedUsers &&
|
||||
other.assets == assets;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetCount.hashCode) +
|
||||
(id.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(albumName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(shared.hashCode) +
|
||||
(sharedUsers.hashCode) +
|
||||
(assets.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetCount.hashCode) +
|
||||
(id.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(albumName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(shared.hashCode) +
|
||||
(sharedUsers.hashCode) +
|
||||
(assets.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
String toString() =>
|
||||
'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'assetCount'] = assetCount;
|
||||
_json[r'id'] = id;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'albumName'] = albumName;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'assetCount'] = assetCount;
|
||||
_json[r'id'] = id;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'albumName'] = albumName;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
if (albumThumbnailAssetId != null) {
|
||||
_json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
|
||||
} else {
|
||||
_json[r'albumThumbnailAssetId'] = null;
|
||||
}
|
||||
_json[r'shared'] = shared;
|
||||
_json[r'sharedUsers'] = sharedUsers;
|
||||
_json[r'assets'] = assets;
|
||||
_json[r'shared'] = shared;
|
||||
_json[r'sharedUsers'] = sharedUsers;
|
||||
_json[r'assets'] = assets;
|
||||
return _json;
|
||||
}
|
||||
|
||||
@@ -98,13 +101,13 @@ class AlbumResponseDto {
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
|
||||
return AlbumResponseDto(
|
||||
assetCount: mapValueOfType<int>(json, r'assetCount')!,
|
||||
@@ -112,7 +115,8 @@ class AlbumResponseDto {
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
albumName: mapValueOfType<String>(json, r'albumName')!,
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
albumThumbnailAssetId:
|
||||
mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
shared: mapValueOfType<bool>(json, r'shared')!,
|
||||
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
|
||||
assets: AssetResponseDto.listFromJson(json[r'assets'])!,
|
||||
@@ -121,7 +125,10 @@ class AlbumResponseDto {
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<AlbumResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <AlbumResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@@ -149,12 +156,18 @@ class AlbumResponseDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of AlbumResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<AlbumResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<AlbumResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
final value = AlbumResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -176,4 +189,3 @@ class AlbumResponseDto {
|
||||
'assets',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ class AssetResponseDto {
|
||||
required this.mimeType,
|
||||
required this.duration,
|
||||
required this.webpPath,
|
||||
required this.encodedVideoPath,
|
||||
this.encodedVideoPath,
|
||||
this.exifInfo,
|
||||
this.smartInfo,
|
||||
required this.livePhotoVideoId,
|
||||
this.livePhotoVideoId,
|
||||
});
|
||||
|
||||
AssetTypeEnum type;
|
||||
@@ -79,74 +79,71 @@ class AssetResponseDto {
|
||||
String? livePhotoVideoId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo &&
|
||||
other.livePhotoVideoId == livePhotoVideoId;
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo &&
|
||||
other.livePhotoVideoId == livePhotoVideoId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
|
||||
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
if (resizePath != null) {
|
||||
_json[r'resizePath'] = resizePath;
|
||||
} else {
|
||||
_json[r'resizePath'] = null;
|
||||
}
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
if (mimeType != null) {
|
||||
_json[r'mimeType'] = mimeType;
|
||||
} else {
|
||||
_json[r'mimeType'] = null;
|
||||
}
|
||||
_json[r'duration'] = duration;
|
||||
_json[r'duration'] = duration;
|
||||
if (webpPath != null) {
|
||||
_json[r'webpPath'] = webpPath;
|
||||
} else {
|
||||
@@ -185,13 +182,13 @@ class AssetResponseDto {
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return AssetResponseDto(
|
||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||
@@ -216,10 +213,7 @@ class AssetResponseDto {
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@@ -247,18 +241,12 @@ class AssetResponseDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -282,7 +270,6 @@ class AssetResponseDto {
|
||||
'mimeType',
|
||||
'duration',
|
||||
'webpPath',
|
||||
'encodedVideoPath',
|
||||
'livePhotoVideoId',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class UserResponseDto {
|
||||
required this.profileImagePath,
|
||||
required this.shouldChangePassword,
|
||||
required this.isAdmin,
|
||||
required this.deletedAt,
|
||||
this.deletedAt,
|
||||
});
|
||||
|
||||
String id;
|
||||
@@ -40,49 +40,52 @@ class UserResponseDto {
|
||||
|
||||
bool isAdmin;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
DateTime? deletedAt;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UserResponseDto &&
|
||||
other.id == id &&
|
||||
other.email == email &&
|
||||
other.firstName == firstName &&
|
||||
other.lastName == lastName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.profileImagePath == profileImagePath &&
|
||||
other.shouldChangePassword == shouldChangePassword &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.deletedAt == deletedAt;
|
||||
bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
|
||||
other.id == id &&
|
||||
other.email == email &&
|
||||
other.firstName == firstName &&
|
||||
other.lastName == lastName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.profileImagePath == profileImagePath &&
|
||||
other.shouldChangePassword == shouldChangePassword &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.deletedAt == deletedAt;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode) +
|
||||
(email.hashCode) +
|
||||
(firstName.hashCode) +
|
||||
(lastName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(profileImagePath.hashCode) +
|
||||
(shouldChangePassword.hashCode) +
|
||||
(isAdmin.hashCode) +
|
||||
(deletedAt == null ? 0 : deletedAt!.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode) +
|
||||
(email.hashCode) +
|
||||
(firstName.hashCode) +
|
||||
(lastName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(profileImagePath.hashCode) +
|
||||
(shouldChangePassword.hashCode) +
|
||||
(isAdmin.hashCode) +
|
||||
(deletedAt == null ? 0 : deletedAt!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
|
||||
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'id'] = id;
|
||||
_json[r'email'] = email;
|
||||
_json[r'firstName'] = firstName;
|
||||
_json[r'lastName'] = lastName;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'profileImagePath'] = profileImagePath;
|
||||
_json[r'shouldChangePassword'] = shouldChangePassword;
|
||||
_json[r'isAdmin'] = isAdmin;
|
||||
_json[r'id'] = id;
|
||||
_json[r'email'] = email;
|
||||
_json[r'firstName'] = firstName;
|
||||
_json[r'lastName'] = lastName;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'profileImagePath'] = profileImagePath;
|
||||
_json[r'shouldChangePassword'] = shouldChangePassword;
|
||||
_json[r'isAdmin'] = isAdmin;
|
||||
if (deletedAt != null) {
|
||||
_json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
@@ -101,13 +104,13 @@ class UserResponseDto {
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return UserResponseDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
@@ -116,8 +119,7 @@ class UserResponseDto {
|
||||
lastName: mapValueOfType<String>(json, r'lastName')!,
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
|
||||
shouldChangePassword:
|
||||
mapValueOfType<bool>(json, r'shouldChangePassword')!,
|
||||
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
|
||||
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
|
||||
deletedAt: mapDateTime(json, r'deletedAt', ''),
|
||||
);
|
||||
@@ -125,10 +127,7 @@ class UserResponseDto {
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UserResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
static List<UserResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UserResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@@ -156,18 +155,12 @@ class UserResponseDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of UserResponseDto-objects as value to a dart map
|
||||
static Map<String, List<UserResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
static Map<String, List<UserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UserResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = UserResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
final value = UserResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -186,6 +179,6 @@ class UserResponseDto {
|
||||
'profileImagePath',
|
||||
'shouldChangePassword',
|
||||
'isAdmin',
|
||||
'deletedAt',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ packages:
|
||||
name: ffi
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
version: "2.0.1"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -554,12 +554,12 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
logging:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logging
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -629,7 +629,7 @@ packages:
|
||||
name: package_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
version: "1.4.3+1"
|
||||
package_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -664,7 +664,7 @@ packages:
|
||||
name: package_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
version: "2.1.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -699,7 +699,7 @@ packages:
|
||||
name: path_provider_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
version: "2.1.7"
|
||||
path_provider_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -720,7 +720,7 @@ packages:
|
||||
name: path_provider_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.6"
|
||||
version: "2.1.3"
|
||||
pedantic:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -998,14 +998,14 @@ packages:
|
||||
name: sqflite
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2+1"
|
||||
version: "2.2.0+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.1+1"
|
||||
version: "2.4.0+2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1257,7 +1257,7 @@ packages:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.5.2"
|
||||
version: "2.7.0"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.36.1+56
|
||||
version: 1.37.0+58
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -47,6 +47,7 @@ dependencies:
|
||||
|
||||
# easy to remove packages:
|
||||
image_picker: ^0.8.5+3 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
|
||||
logging: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -71,7 +72,9 @@ flutter:
|
||||
- family: SnowburstOne
|
||||
fonts:
|
||||
- asset: fonts/SnowburstOne.ttf
|
||||
|
||||
- family: Inconsolata
|
||||
fonts:
|
||||
- asset: fonts/Inconsolata-Regular.ttf
|
||||
flutter_icons:
|
||||
image_path_android: "assets/immich-logo-no-outline.png"
|
||||
image_path_ios: "assets/immich-logo-no-outline.png"
|
||||
|
||||
@@ -21,12 +21,12 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { assetUploadOption } from '../../config/asset-upload.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
import { Response as Res } from 'express';
|
||||
import { Response as Res} from 'express';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||
import { AssetResponseDto } from './response-dto/asset-response.dto';
|
||||
@@ -108,7 +108,7 @@ export class AssetController {
|
||||
}
|
||||
|
||||
@Get('/file/:assetId')
|
||||
@Header('Cache-Control', 'max-age=300')
|
||||
@Header('Cache-Control', 'max-age=3600')
|
||||
async serveFile(
|
||||
@Headers() headers: Record<string, string>,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
@@ -119,13 +119,14 @@ export class AssetController {
|
||||
}
|
||||
|
||||
@Get('/thumbnail/:assetId')
|
||||
@Header('Cache-Control', 'max-age=300')
|
||||
@Header('Cache-Control', 'max-age=3600')
|
||||
async getAssetThumbnail(
|
||||
@Headers() headers: Record<string, string>,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
@Param('assetId') assetId: string,
|
||||
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
|
||||
): Promise<any> {
|
||||
return this.assetService.getAssetThumbnail(assetId, query, res);
|
||||
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
|
||||
}
|
||||
|
||||
@Get('/curated-objects')
|
||||
@@ -168,8 +169,15 @@ export class AssetController {
|
||||
* Get all AssetEntity belong to the user
|
||||
*/
|
||||
@Get('/')
|
||||
@ApiHeader({
|
||||
name: 'if-none-match',
|
||||
description: 'ETag of data already cached on the client',
|
||||
required: false,
|
||||
schema: { type: 'string' },
|
||||
})
|
||||
async getAllAssets(@GetAuthUser() authUser: AuthUserDto): Promise<AssetResponseDto[]> {
|
||||
return await this.assetService.getAllAssets(authUser);
|
||||
const assets = await this.assetService.getAllAssets(authUser);
|
||||
return assets;
|
||||
}
|
||||
|
||||
@Post('/time-bucket')
|
||||
|
||||
@@ -306,7 +306,12 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getAssetThumbnail(assetId: string, query: GetAssetThumbnailDto, res: Res) {
|
||||
public async getAssetThumbnail(
|
||||
assetId: string,
|
||||
query: GetAssetThumbnailDto,
|
||||
res: Res,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
let fileReadStream: ReadStream;
|
||||
|
||||
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
|
||||
@@ -316,28 +321,22 @@ export class AssetService {
|
||||
}
|
||||
|
||||
try {
|
||||
if (query.format == GetAssetThumbnailFormatEnum.JPEG) {
|
||||
if (query.format == GetAssetThumbnailFormatEnum.WEBP && asset.webpPath && asset.webpPath.length > 0) {
|
||||
if (await processETag(asset.webpPath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
await fs.access(asset.webpPath, constants.R_OK);
|
||||
fileReadStream = createReadStream(asset.webpPath);
|
||||
} else {
|
||||
if (!asset.resizePath) {
|
||||
throw new NotFoundException('resizePath not set');
|
||||
}
|
||||
|
||||
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.resizePath);
|
||||
} else {
|
||||
if (asset.webpPath && asset.webpPath.length > 0) {
|
||||
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.webpPath);
|
||||
} else {
|
||||
if (!asset.resizePath) {
|
||||
throw new NotFoundException('resizePath not set');
|
||||
}
|
||||
|
||||
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.resizePath);
|
||||
if (await processETag(asset.resizePath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
await fs.access(asset.resizePath, constants.R_OK);
|
||||
fileReadStream = createReadStream(asset.resizePath);
|
||||
}
|
||||
|
||||
res.header('Cache-Control', 'max-age=300');
|
||||
return new StreamableFile(fileReadStream);
|
||||
} catch (e) {
|
||||
res.header('Cache-Control', 'none');
|
||||
@@ -349,7 +348,7 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: any) {
|
||||
public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: Record<string, string>) {
|
||||
let fileReadStream: ReadStream;
|
||||
const asset = await this._assetRepository.getById(assetId);
|
||||
|
||||
@@ -371,6 +370,9 @@ export class AssetService {
|
||||
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
|
||||
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
|
||||
}
|
||||
if (await processETag(asset.resizePath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.resizePath);
|
||||
|
||||
@@ -384,7 +386,9 @@ export class AssetService {
|
||||
res.set({
|
||||
'Content-Type': asset.mimeType,
|
||||
});
|
||||
|
||||
if (await processETag(asset.originalPath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.originalPath);
|
||||
} else {
|
||||
@@ -392,7 +396,9 @@ export class AssetService {
|
||||
res.set({
|
||||
'Content-Type': 'image/webp',
|
||||
});
|
||||
|
||||
if (await processETag(asset.webpPath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.webpPath);
|
||||
} else {
|
||||
@@ -403,6 +409,9 @@ export class AssetService {
|
||||
if (!asset.resizePath) {
|
||||
throw new Error('resizePath not set');
|
||||
}
|
||||
if (await processETag(asset.resizePath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.resizePath);
|
||||
@@ -436,9 +445,9 @@ export class AssetService {
|
||||
|
||||
if (range) {
|
||||
/** Extracting Start and End value from Range Header */
|
||||
let [start, end] = range.replace(/bytes=/, '').split('-');
|
||||
start = parseInt(start, 10);
|
||||
end = end ? parseInt(end, 10) : size - 1;
|
||||
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||
let start = parseInt(startStr, 10);
|
||||
let end = endStr ? parseInt(endStr, 10) : size - 1;
|
||||
|
||||
if (!isNaN(start) && isNaN(end)) {
|
||||
start = start;
|
||||
@@ -475,7 +484,9 @@ export class AssetService {
|
||||
res.set({
|
||||
'Content-Type': mimeType,
|
||||
});
|
||||
|
||||
if (await processETag(asset.originalPath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
return new StreamableFile(createReadStream(videoPath));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -632,3 +643,14 @@ export class AssetService {
|
||||
return this._assetRepository.getAssetCountByUserId(authUser.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function processETag(path: string, res: Res, headers: Record<string, string>): Promise<boolean> {
|
||||
const { size, mtimeNs } = await fs.stat(path, { bigint: true });
|
||||
const etag = `W/"${size}-${mtimeNs}"`;
|
||||
res.setHeader('ETag', etag);
|
||||
if (etag === headers['if-none-match']) {
|
||||
res.status(304);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ export class AssetResponseDto {
|
||||
mimeType!: string | null;
|
||||
duration!: string;
|
||||
webpPath!: string | null;
|
||||
encodedVideoPath!: string | null;
|
||||
encodedVideoPath?: string | null;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
livePhotoVideoId!: string | null;
|
||||
livePhotoVideoId?: string | null;
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
|
||||
@@ -9,7 +9,7 @@ export class UserResponseDto {
|
||||
profileImagePath!: string;
|
||||
shouldChangePassword!: boolean;
|
||||
isAdmin!: boolean;
|
||||
deletedAt!: Date | null;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
@@ -22,6 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isAdmin: entity.isAdmin,
|
||||
deletedAt: entity.deletedAt || null,
|
||||
deletedAt: entity.deletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,5 +127,16 @@ describe('UserService', () => {
|
||||
});
|
||||
expect(result).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('cannot delete admin user', () => {
|
||||
const requestor = adminAuthUser;
|
||||
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
|
||||
|
||||
const result = sui.deleteUser(requestor, adminAuthUser.id);
|
||||
|
||||
expect(result).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +119,11 @@ export class UserService {
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
throw new BadRequestException('Cannot delete admin user');
|
||||
}
|
||||
|
||||
try {
|
||||
const deletedUser = await this.userRepository.delete(user);
|
||||
return mapUser(deletedUser);
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
||||
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 36,
|
||||
patch: 2,
|
||||
build: 56,
|
||||
minor: 37,
|
||||
patch: 0,
|
||||
build: 58,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
app.set('trust proxy');
|
||||
app.set('etag', 'strong');
|
||||
app.use(cookieParser());
|
||||
app.use(json({ limit: '10mb' }));
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
|
||||
@@ -35,7 +35,7 @@ export class DownloadService {
|
||||
fileCount++;
|
||||
|
||||
// for easier testing, can be changed before merging.
|
||||
if (totalSize > HumanReadableSize.GB * 20) {
|
||||
if (totalSize > HumanReadableSize.GiB * 20) {
|
||||
complete = false;
|
||||
this.logger.log(
|
||||
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
const KB = 1000;
|
||||
const MB = KB * 1000;
|
||||
const GB = MB * 1000;
|
||||
const TB = GB * 1000;
|
||||
const PB = TB * 1000;
|
||||
const KiB = Math.pow(1024, 1);
|
||||
const MiB = Math.pow(1024, 2);
|
||||
const GiB = Math.pow(1024, 3);
|
||||
const TiB = Math.pow(1024, 4);
|
||||
const PiB = Math.pow(1024, 5);
|
||||
|
||||
export const HumanReadableSize = { KB, MB, GB, TB, PB };
|
||||
export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB };
|
||||
|
||||
export function asHumanReadable(bytes: number, precision = 1) {
|
||||
if (bytes >= PB) {
|
||||
return `${(bytes / PB).toFixed(precision)}PB`;
|
||||
}
|
||||
export function asHumanReadable(bytes: number, precision = 1): string {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
|
||||
|
||||
if (bytes >= TB) {
|
||||
return `${(bytes / TB).toFixed(precision)}TB`;
|
||||
}
|
||||
let magnitude = 0;
|
||||
let remainder = bytes;
|
||||
while (remainder >= 1024) {
|
||||
if (magnitude + 1 < units.length) {
|
||||
magnitude++;
|
||||
remainder /= 1024;
|
||||
}
|
||||
else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes >= GB) {
|
||||
return `${(bytes / GB).toFixed(precision)}GB`;
|
||||
}
|
||||
|
||||
if (bytes >= MB) {
|
||||
return `${(bytes / MB).toFixed(precision)}MB`;
|
||||
}
|
||||
|
||||
if (bytes >= KB) {
|
||||
return `${(bytes / KB).toFixed(precision)}KB`;
|
||||
}
|
||||
|
||||
return `${bytes}B`;
|
||||
return `${remainder.toFixed( magnitude == 0 ? 0 : precision )} ${units[magnitude]}`;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -427,7 +427,7 @@ export interface AssetResponseDto {
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'encodedVideoPath': string | null;
|
||||
'encodedVideoPath'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {ExifResponseDto}
|
||||
@@ -445,7 +445,7 @@ export interface AssetResponseDto {
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'livePhotoVideoId': string | null;
|
||||
'livePhotoVideoId'?: string | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@@ -1729,7 +1729,7 @@ export interface UserResponseDto {
|
||||
* @type {string}
|
||||
* @memberof UserResponseDto
|
||||
*/
|
||||
'deletedAt': string | null;
|
||||
'deletedAt'?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@@ -2788,10 +2788,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAllAssets: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
getAllAssets: async (ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/asset`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
@@ -2808,6 +2809,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (ifNoneMatch !== undefined && ifNoneMatch !== null) {
|
||||
localVarHeaderParameter['if-none-match'] = String(ifNoneMatch);
|
||||
}
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
@@ -3388,11 +3393,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getAllAssets(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(options);
|
||||
async getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(ifNoneMatch, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@@ -3590,11 +3596,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAllAssets(options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
||||
return localVarFp.getAllAssets(options).then((request) => request(axios, basePath));
|
||||
getAllAssets(ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
||||
return localVarFp.getAllAssets(ifNoneMatch, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Get a single asset\'s information
|
||||
@@ -3788,12 +3795,13 @@ export class AssetApi extends BaseAPI {
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof AssetApi
|
||||
*/
|
||||
public getAllAssets(options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAllAssets(options).then((request) => request(this.axios, this.basePath));
|
||||
public getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAllAssets(ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
return name;
|
||||
};
|
||||
|
||||
$: spaceUnit = stats.usage.slice(stats.usage.length - 2, stats.usage.length);
|
||||
$: spaceUsage = stats.usage.slice(0, stats.usage.length - 2);
|
||||
$: spaceUnit = stats.usage.split(' ')[1];
|
||||
$: spaceUsage = stats.usage.split(' ')[0];
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-16 bg-black/5 flex justify-between place-items-center px-3 transition-transform duration-200 z-[9999]"
|
||||
class="h-16 flex justify-between place-items-center px-3 transition-transform duration-200 z-[9999]"
|
||||
>
|
||||
<div>
|
||||
<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
||||
import { getHumanReadableBytes } from '../../utils/byte-units';
|
||||
|
||||
type Leaflet = typeof import('leaflet');
|
||||
type LeafletMap = import('leaflet').Map;
|
||||
@@ -59,32 +60,6 @@
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const getHumanReadableString = (sizeInByte: number) => {
|
||||
const pepibyte = 1.126 * Math.pow(10, 15);
|
||||
const tebibyte = 1.1 * Math.pow(10, 12);
|
||||
const gibibyte = 1.074 * Math.pow(10, 9);
|
||||
const mebibyte = 1.049 * Math.pow(10, 6);
|
||||
const kibibyte = 1024;
|
||||
// Pebibyte
|
||||
if (sizeInByte >= pepibyte) {
|
||||
// Pe
|
||||
return `${(sizeInByte / pepibyte).toFixed(1)}PB`;
|
||||
} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) {
|
||||
// Te
|
||||
return `${(sizeInByte / tebibyte).toFixed(1)}TB`;
|
||||
} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) {
|
||||
// Gi
|
||||
return `${(sizeInByte / gibibyte).toFixed(1)}GB`;
|
||||
} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) {
|
||||
// Mega
|
||||
return `${(sizeInByte / mebibyte).toFixed(1)}MB`;
|
||||
} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) {
|
||||
// Kibi
|
||||
return `${(sizeInByte / kibibyte).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${sizeInByte}B`;
|
||||
}
|
||||
};
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
const megapixel = Math.round((height * width) / 1_000_000);
|
||||
@@ -143,13 +118,13 @@
|
||||
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
|
||||
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
|
||||
<p>
|
||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}MP
|
||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
||||
{/if}
|
||||
<p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p>
|
||||
<p>{getHumanReadableBytes(asset.exifInfo.fileSizeInByte)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +137,7 @@
|
||||
<div>
|
||||
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
|
||||
<div class="flex text-sm gap-2">
|
||||
<p>{`f/${asset.exifInfo.fNumber}` || ''}</p>
|
||||
<p>{`ƒ/${asset.exifInfo.fNumber}` || ''}</p>
|
||||
|
||||
{#if asset.exifInfo.exposureTime}
|
||||
<p>{`1/${Math.floor(1 / asset.exifInfo.exposureTime)}`}</p>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
<div
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class="fixed right-0 bg-immich-bg z-[50] hover:cursor-row-resize select-none "
|
||||
class="fixed right-0 bg-immich-bg z-[100] hover:cursor-row-resize select-none "
|
||||
style:width={isDragging ? '100vw' : '60px'}
|
||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
@@ -109,7 +109,7 @@
|
||||
>
|
||||
{#if isHover}
|
||||
<div
|
||||
class="border-b-2 border-immich-primary dark:border-immich-dark-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-immich-bg dark:bg-immich-dark-gray z-50 pointer-events-none rounded-tl-md shadow-lg dark:text-immich-dark-fg"
|
||||
class="border-b-2 border-immich-primary dark:border-immich-dark-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-immich-bg dark:bg-immich-dark-gray z-[100] pointer-events-none rounded-tl-md shadow-lg dark:text-immich-dark-fg"
|
||||
style:top={currentMouseYLocation + 'px'}
|
||||
>
|
||||
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||
import { notificationController, NotificationType } from './notification/notification';
|
||||
import { getHumanReadableBytes } from '../../utils/byte-units';
|
||||
|
||||
let showDetail = true;
|
||||
|
||||
let uploadLength = 0;
|
||||
@@ -30,33 +32,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
function getSizeInHumanReadableFormat(sizeInByte: number) {
|
||||
const pepibyte = 1.126 * Math.pow(10, 15);
|
||||
const tebibyte = 1.1 * Math.pow(10, 12);
|
||||
const gibibyte = 1.074 * Math.pow(10, 9);
|
||||
const mebibyte = 1.049 * Math.pow(10, 6);
|
||||
const kibibyte = 1024;
|
||||
// Pebibyte
|
||||
if (sizeInByte >= pepibyte) {
|
||||
// Pe
|
||||
return `${(sizeInByte / pepibyte).toFixed(1)}PB`;
|
||||
} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) {
|
||||
// Te
|
||||
return `${(sizeInByte / tebibyte).toFixed(1)}TB`;
|
||||
} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) {
|
||||
// Gi
|
||||
return `${(sizeInByte / gibibyte).toFixed(1)}GB`;
|
||||
} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) {
|
||||
// Mega
|
||||
return `${(sizeInByte / mebibyte).toFixed(1)}MB`;
|
||||
} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) {
|
||||
// Kibi
|
||||
return `${(sizeInByte / kibibyte).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${sizeInByte}B`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive action to get thumbnail image of upload asset whenever there is a new one added to the list
|
||||
$: {
|
||||
if ($uploadAssetsStore.length != uploadLength) {
|
||||
@@ -140,7 +115,7 @@
|
||||
<input
|
||||
disabled
|
||||
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
|
||||
value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${
|
||||
value={`[${getHumanReadableBytes(uploadAsset.file.size)}] ${
|
||||
uploadAsset.file.name
|
||||
}`}
|
||||
/>
|
||||
|
||||
16
web/src/lib/utils/byte-units.ts
Normal file
16
web/src/lib/utils/byte-units.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function getHumanReadableBytes(bytes: number): string {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
|
||||
|
||||
let magnitude = 0;
|
||||
let remainder = bytes;
|
||||
while (remainder >= 1024) {
|
||||
if (magnitude + 1 < units.length) {
|
||||
magnitude++;
|
||||
remainder /= 1024;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${remainder.toFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user