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).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId "app.alextran.immich"
|
applicationId "app.alextran.immich"
|
||||||
minSdkVersion 23
|
minSdkVersion 23
|
||||||
targetSdkVersion flutter.targetSdkVersion
|
targetSdkVersion 33
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_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.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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 56,
|
"android.injected.version.code" => 58,
|
||||||
"android.injected.version.name" => "1.36.1",
|
"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')
|
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_device": "Albums on device ({})",
|
||||||
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
"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_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_selection_info": "Selection Info",
|
||||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||||
"backup_all": "All",
|
"backup_all": "All",
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
||||||
"profile_drawer_settings": "Settings",
|
"profile_drawer_settings": "Settings",
|
||||||
"profile_drawer_sign_out": "Sign Out",
|
"profile_drawer_sign_out": "Sign Out",
|
||||||
|
"profile_drawer_app_logs": "Logs",
|
||||||
"search_bar_hint": "Search your photos",
|
"search_bar_hint": "Search your photos",
|
||||||
"search_page_no_objects": "No Objects Info Available",
|
"search_page_no_objects": "No Objects Info Available",
|
||||||
"search_page_no_places": "No Places 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"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.36.1"
|
version_number: "1.37.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
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 deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
|
||||||
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
|
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
|
||||||
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
|
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
|
||||||
|
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
|
||||||
|
|
||||||
// Login Info
|
// Login Info
|
||||||
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
|
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
|
||||||
@@ -29,3 +30,6 @@ const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
|
|||||||
// Duplicate asset
|
// Duplicate asset
|
||||||
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
|
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
|
||||||
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
|
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/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/routing/tab_navigation_observer.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/app_state.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.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/release_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/server_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/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/immich_loading_overlay.dart';
|
||||||
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
||||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||||
@@ -31,8 +33,10 @@ void main() async {
|
|||||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
||||||
|
Hive.registerAdapter(ImmichLoggerMessageAdapter());
|
||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
|
Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
|
||||||
Hive.openBox(userInfoBox),
|
Hive.openBox(userInfoBox),
|
||||||
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
|
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
|
||||||
Hive.openBox(hiveGithubReleaseInfoBox),
|
Hive.openBox(hiveGithubReleaseInfoBox),
|
||||||
@@ -58,6 +62,9 @@ void main() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Immich Logger Service
|
||||||
|
ImmichLogger().init();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
EasyLocalization(
|
EasyLocalization(
|
||||||
supportedLocales: locales,
|
supportedLocales: locales,
|
||||||
|
|||||||
@@ -21,8 +21,39 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var box = Hive.box(userInfoBox);
|
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(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -35,19 +66,9 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: album.albumThumbnailAssetId == null
|
||||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
? buildEmptyThumbnail()
|
||||||
width: cardSize,
|
: buildAlbumThumbnail(),
|
||||||
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}",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
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:openapi/api.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
|
|
||||||
class ExifBottomSheet extends ConsumerWidget {
|
class ExifBottomSheet extends ConsumerWidget {
|
||||||
final Asset assetDetail;
|
final Asset assetDetail;
|
||||||
@@ -162,7 +163,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
subtitle: exifInfo.exifImageHeight != null
|
subtitle: exifInfo.exifImageHeight != null
|
||||||
? Text(
|
? Text(
|
||||||
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${exifInfo.fileSizeInByte!}B ",
|
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte!)} ",
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -178,7 +179,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
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<HiveDuplicatedAssets>(duplicatedAssetsBox),
|
||||||
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
|
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ApiService apiService = ApiService();
|
ApiService apiService = ApiService();
|
||||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||||
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:cancellation_token_http/http.dart';
|
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:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.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/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
@@ -62,11 +63,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
getBackupInfo();
|
getBackupInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final log = Logger('BackupNotifier');
|
||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final ServerInfoService _serverInfoService;
|
final ServerInfoService _serverInfoService;
|
||||||
final AuthenticationState _authState;
|
final AuthenticationState _authState;
|
||||||
final BackgroundService _backgroundService;
|
final BackgroundService _backgroundService;
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
var isGettingBackupInfo = false;
|
||||||
|
|
||||||
///
|
///
|
||||||
/// UI INTERACTION
|
/// UI INTERACTION
|
||||||
@@ -171,9 +174,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
/// Get all album on the device
|
/// Get all album on the device
|
||||||
/// Get all selected and excluded album from the user's persistent storage
|
/// 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
|
/// 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 {
|
Future<void> _getBackupAlbumsInfo() async {
|
||||||
|
Stopwatch stopwatch = Stopwatch()..start();
|
||||||
// Get all albums on the device
|
// Get all albums on the device
|
||||||
List<AvailableAlbum> availableAlbums = [];
|
List<AvailableAlbum> availableAlbums = [];
|
||||||
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
|
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
|
||||||
@@ -181,6 +185,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
type: RequestType.common,
|
type: RequestType.common,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
log.info('Found ${albums.length} local albums');
|
||||||
|
|
||||||
for (AssetPathEntity album in albums) {
|
for (AssetPathEntity album in albums) {
|
||||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||||
|
|
||||||
@@ -218,13 +224,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (backupAlbumInfo == null) {
|
if (backupAlbumInfo == null) {
|
||||||
debugPrint("[ERROR] getting Hive backup album infomation");
|
log.severe(
|
||||||
|
"backupAlbumInfo == null",
|
||||||
|
"Failed to get Hive backup album information",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First time backup - set isAll album is the default one for backup.
|
// First time backup - set isAll album is the default one for backup.
|
||||||
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
|
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
|
// Get album that contains all assets
|
||||||
var list = await PhotoManager.getAssetPathList(
|
var list = await PhotoManager.getAssetPathList(
|
||||||
@@ -286,9 +295,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
selectedBackupAlbums: selectedAlbums,
|
selectedBackupAlbums: selectedAlbums,
|
||||||
excludedBackupAlbums: excludedAlbums,
|
excludedBackupAlbums: excludedAlbums,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint("[ERROR] Failed to generate album from id $e");
|
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) {
|
if (allUniqueAssets.isEmpty) {
|
||||||
debugPrint("No Asset On Device");
|
log.info("Not found albums or assets on the device to backup");
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
allAssetsInDatabase: allAssetsInDatabase,
|
allAssetsInDatabase: allAssetsInDatabase,
|
||||||
@@ -360,25 +371,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
|
||||||
/// Get all necessary information for calculating the available albums,
|
/// Get all necessary information for calculating the available albums,
|
||||||
/// which albums are selected or excluded
|
/// which albums are selected or excluded
|
||||||
/// and then update the UI according to those information
|
/// and then update the UI according to those information
|
||||||
///
|
|
||||||
Future<void> getBackupInfo() async {
|
Future<void> getBackupInfo() async {
|
||||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
if (!isGettingBackupInfo) {
|
||||||
state = state.copyWith(backgroundBackup: isEnabled);
|
isGettingBackupInfo = true;
|
||||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
|
||||||
await _getBackupAlbumsInfo();
|
var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||||
await _updateServerInfo();
|
|
||||||
await _updateBackupAssetCount();
|
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
|
/// Save user selection of selected albums and excluded albums to
|
||||||
/// Hive database
|
/// Hive database
|
||||||
///
|
|
||||||
void _updatePersistentAlbumsSelection() {
|
void _updatePersistentAlbumsSelection() {
|
||||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||||
@@ -398,9 +413,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
|
||||||
/// Invoke backup process
|
/// Invoke backup process
|
||||||
///
|
|
||||||
Future<void> startBackupProcess() async {
|
Future<void> startBackupProcess() async {
|
||||||
assert(state.backupProgress == BackUpProgressEnum.idle);
|
assert(state.backupProgress == BackUpProgressEnum.idle);
|
||||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||||
@@ -412,7 +425,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
await PhotoManager.clearFileCache();
|
await PhotoManager.clearFileCache();
|
||||||
|
|
||||||
if (state.allUniqueAssets.isEmpty) {
|
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);
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -530,7 +543,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
|
|
||||||
// User has been logged out return
|
// User has been logged out return
|
||||||
if (accessKey == null || !_authState.isAuthenticated) {
|
if (accessKey == null || !_authState.isAuthenticated) {
|
||||||
debugPrint("[resumeBackup] not authenticated - abort");
|
log.info("[_resumeBackup] not authenticated - abort");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,17 +552,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
_authState.deviceInfo.isAutoBackup) {
|
_authState.deviceInfo.isAutoBackup) {
|
||||||
// check if backup is alreayd in process - then return
|
// check if backup is alreayd in process - then return
|
||||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||||
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
log.info("[_resumeBackup] Backup is already in progress - abort");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.backupProgress == BackUpProgressEnum.inBackground) {
|
if (state.backupProgress == BackUpProgressEnum.inBackground) {
|
||||||
debugPrint("[resumeBackup] Background backup is running - abort");
|
log.info("[_resumeBackup] Background backup is running - abort");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run backup
|
// Run backup
|
||||||
debugPrint("[resumeBackup] Start back up");
|
log.info("[_resumeBackup] Start back up");
|
||||||
await startBackupProcess();
|
await startBackupProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +578,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
|
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
|
||||||
final bool hasLock = await _backgroundService.acquireLock();
|
final bool hasLock = await _backgroundService.acquireLock();
|
||||||
if (!hasLock) {
|
if (!hasLock) {
|
||||||
debugPrint("WARNING [resumeBackup] failed to acquireLock");
|
log.warning("WARNING [resumeBackup] failed to acquireLock");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
@@ -612,7 +625,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
|
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
|
||||||
result.add(a.copyWith(lastBackup: times[i]));
|
result.add(a.copyWith(lastBackup: times[i]));
|
||||||
} on StateError {
|
} 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;
|
return result;
|
||||||
@@ -631,21 +648,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
|
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
log.info("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
|
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
|
||||||
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
|
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error, stackTrace) {
|
||||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
log.severe(
|
||||||
|
"[_notifyBackgroundServiceCanRun] failed to close box",
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
||||||
await Hive.box(backgroundBackupInfoBox).close();
|
await Hive.box(backgroundBackupInfoBox).close();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error, stackTrace) {
|
||||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
log.severe(
|
||||||
|
"[_notifyBackgroundServiceCanRun] failed to close box",
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_backgroundService.releaseLock();
|
_backgroundService.releaseLock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
|
|||||||
title: Column(
|
title: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"${album.name} (${album.assetCountAsync})",
|
album.name,
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/immich_colors.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/providers/backup.provider.dart';
|
||||||
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
|
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.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);
|
const BackupAlbumSelectionPage({Key? key}) : super(key: key);
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||||
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final albums = useState<List<AvailableAlbum>>(
|
||||||
|
ref.watch(backupProvider).availableAlbums,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -28,7 +32,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
buildAlbumSelectionList() {
|
buildAlbumSelectionList() {
|
||||||
if (availableAlbums.isEmpty) {
|
if (albums.value.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: ImmichLoadingIndicator(),
|
child: ImmichLoadingIndicator(),
|
||||||
);
|
);
|
||||||
@@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
height: 265,
|
height: 265,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: availableAlbums.length,
|
itemCount: albums.value.length,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
itemBuilder: ((context, index) {
|
itemBuilder: ((context, index) {
|
||||||
var thumbnailData = availableAlbums[index].thumbnailData;
|
var thumbnailData = albums.value[index].thumbnailData;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: index == 0
|
padding: index == 0
|
||||||
? const EdgeInsets.only(left: 16.00)
|
? const EdgeInsets.only(left: 16.00)
|
||||||
: const EdgeInsets.all(0),
|
: const EdgeInsets.all(0),
|
||||||
child: AlbumInfoCard(
|
child: AlbumInfoCard(
|
||||||
imageData: thumbnailData,
|
imageData: thumbnailData,
|
||||||
albumInfo: availableAlbums[index],
|
albumInfo: albums.value[index],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
child: Chip(
|
child: Chip(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
album.name,
|
album.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: Theme.of(context).brightness == Brightness.dark
|
color: isDarkTheme ? Colors.black : Colors.white,
|
||||||
? Colors.black
|
|
||||||
: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
child: Chip(
|
child: Chip(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
album.name,
|
album.name,
|
||||||
@@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
}).toSet();
|
}).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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
@@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
child: Card(
|
child: Card(
|
||||||
margin: const EdgeInsets.all(0),
|
margin: const EdgeInsets.all(0),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(10),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: isDarkTheme
|
color: isDarkTheme
|
||||||
? const Color.fromARGB(255, 0, 0, 0)
|
? const Color.fromARGB(255, 0, 0, 0)
|
||||||
@@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
"backup_album_selection_page_albums_device"
|
"backup_album_selection_page_albums_device".tr(
|
||||||
.tr(args: [availableAlbums.length.toString()]),
|
args: [
|
||||||
|
ref.watch(backupProvider).availableAlbums.length.toString()
|
||||||
|
],
|
||||||
|
),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
),
|
),
|
||||||
subtitle: Padding(
|
subtitle: Padding(
|
||||||
@@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -284,6 +329,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
buildSearchBar(),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16.0),
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
child: buildAlbumSelectionList(),
|
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/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.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:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
final assetServiceProvider = Provider(
|
final assetServiceProvider = Provider(
|
||||||
(ref) => AssetService(
|
(ref) => AssetService(
|
||||||
@@ -25,42 +27,31 @@ class AssetService {
|
|||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final BackgroundService _backgroundService;
|
final BackgroundService _backgroundService;
|
||||||
|
final log = Logger('AssetService');
|
||||||
|
|
||||||
AssetService(this._apiService, this._backupService, this._backgroundService);
|
AssetService(this._apiService, this._backupService, this._backgroundService);
|
||||||
|
|
||||||
/// Returns all local, remote assets in that order
|
/// Returns `null` if the server state did not change, else list of assets
|
||||||
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
|
Future<List<Asset>?> getRemoteAssets({required bool hasCache}) async {
|
||||||
final List<Asset> assets = [];
|
|
||||||
try {
|
try {
|
||||||
// not using `await` here to fetch local & remote assets concurrently
|
final Box box = Hive.box(userInfoBox);
|
||||||
final Future<List<AssetResponseDto>?> remoteTask =
|
final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
|
||||||
_apiService.assetApi.getAllAssets();
|
.assetApi
|
||||||
final Iterable<AssetEntity> newLocalAssets;
|
.getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null);
|
||||||
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
|
if (remote == null) {
|
||||||
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
|
return null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
box.put(assetEtagKey, remote.second);
|
||||||
assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
|
return remote.first.map(Asset.remote).toList(growable: false);
|
||||||
// the order (first all local, then remote assets) is important!
|
} catch (e, stack) {
|
||||||
assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
|
log.severe('Error while getting remote assets', e, stack);
|
||||||
} catch (e) {
|
return null;
|
||||||
debugPrint("Error [getAllAsset] ${e.toString()}");
|
|
||||||
}
|
}
|
||||||
return assets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// if [urgent] is `true`, do not block by waiting on the background service
|
/// if [urgent] is `true`, do not block by waiting on the background service
|
||||||
/// to finish running. Returns an empty list instead after a timeout.
|
/// to finish running. Returns `null` instead after a timeout.
|
||||||
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
|
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
|
||||||
try {
|
try {
|
||||||
final Future<bool> hasAccess = urgent
|
final Future<bool> hasAccess = urgent
|
||||||
? _backgroundService.hasAccess
|
? _backgroundService.hasAccess
|
||||||
@@ -71,15 +62,16 @@ class AssetService {
|
|||||||
}
|
}
|
||||||
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||||
|
if (backupAlbumInfo != null) {
|
||||||
return backupAlbumInfo != null
|
return (await _backupService
|
||||||
? await _backupService
|
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
|
||||||
.buildUploadCandidates(backupAlbumInfo.deepCopy())
|
.map(Asset.local)
|
||||||
: [];
|
.toList(growable: false);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error [_getLocalAssets] ${e.toString()}");
|
debugPrint("Error [_getLocalAssets] ${e.toString()}");
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Asset?> getAssetById(String assetId) async {
|
Future<Asset?> getAssetById(String assetId) async {
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
snap: false,
|
snap: false,
|
||||||
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
|
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
borderRadius: BorderRadius.all(
|
||||||
|
Radius.circular(5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
leading: Builder(
|
leading: Builder(
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/profile_drawer_header.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.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/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.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';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
|
|
||||||
class ProfileDrawer extends HookConsumerWidget {
|
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(
|
return Drawer(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -80,6 +104,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
const ProfileDrawerHeader(),
|
const ProfileDrawerHeader(),
|
||||||
buildSettingButton(),
|
buildSettingButton(),
|
||||||
|
buildAppLogButton(),
|
||||||
buildSignoutButton(),
|
buildSignoutButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.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/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:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -37,6 +40,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
final albums = ref.watch(albumProvider);
|
final albums = ref.watch(albumProvider);
|
||||||
final albumService = ref.watch(albumServiceProvider);
|
final albumService = ref.watch(albumServiceProvider);
|
||||||
|
|
||||||
|
final tipOneOpacity = useState(0.0);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
ref.read(websocketProvider.notifier).connect();
|
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(
|
return SafeArea(
|
||||||
bottom: !multiselectEnabled.state,
|
bottom: !multiselectEnabled.state,
|
||||||
top: true,
|
top: true,
|
||||||
@@ -164,15 +212,17 @@ class HomePage extends HookConsumerWidget {
|
|||||||
top: selectionEnabledHook.value ? 0 : 60,
|
top: selectionEnabledHook.value ? 0 : 60,
|
||||||
bottom: 0.0,
|
bottom: 0.0,
|
||||||
),
|
),
|
||||||
child: ImmichAssetGrid(
|
child: ref.watch(assetProvider).isEmpty
|
||||||
renderList: renderList,
|
? buildLoadingIndicator()
|
||||||
assetsPerRow:
|
: ImmichAssetGrid(
|
||||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
renderList: renderList,
|
||||||
showStorageIndicator: appSettingService
|
assetsPerRow: appSettingService
|
||||||
.getSetting(AppSettingsEnum.storageIndicator),
|
.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
listener: selectionListener,
|
showStorageIndicator: appSettingService
|
||||||
selectionActive: selectionEnabledHook.value,
|
.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
),
|
listener: selectionListener,
|
||||||
|
selectionActive: selectionEnabledHook.value,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (selectionEnabledHook.value)
|
if (selectionEnabledHook.value)
|
||||||
ControlBottomAppBar(
|
ControlBottomAppBar(
|
||||||
|
|||||||
@@ -101,11 +101,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> logout() async {
|
Future<bool> logout() async {
|
||||||
Hive.box(userInfoBox).delete(accessTokenKey);
|
|
||||||
state = state.copyWith(isAuthenticated: false);
|
state = state.copyWith(isAuthenticated: false);
|
||||||
_assetCacheService.invalidate();
|
await Future.wait([
|
||||||
_albumCacheService.invalidate();
|
Hive.box(userInfoBox).delete(accessTokenKey),
|
||||||
_sharedAlbumCacheService.invalidate();
|
Hive.box(userInfoBox).delete(assetEtagKey),
|
||||||
|
_assetCacheService.invalidate(),
|
||||||
|
_albumCacheService.invalidate(),
|
||||||
|
_sharedAlbumCacheService.invalidate(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Remove login info from local storage
|
// Remove login info from local storage
|
||||||
var loginInfo =
|
var loginInfo =
|
||||||
@@ -115,7 +118,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
loginInfo.password = "";
|
loginInfo.password = "";
|
||||||
loginInfo.isSaveLogin = false;
|
loginInfo.isSaveLogin = false;
|
||||||
|
|
||||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
|
await Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
|
||||||
savedLoginInfoKey,
|
savedLoginInfoKey,
|
||||||
loginInfo,
|
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(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 300),
|
constraints: const BoxConstraints(maxWidth: 300),
|
||||||
@@ -92,10 +99,13 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
runSpacing: 16,
|
runSpacing: 16,
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Image(
|
GestureDetector(
|
||||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
onDoubleTap: () => populateTestLoginInfo(),
|
||||||
width: 100,
|
child: const Image(
|
||||||
filterQuality: FilterQuality.high,
|
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||||
|
width: 100,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'IMMICH',
|
'IMMICH',
|
||||||
|
|||||||
@@ -1,14 +1,65 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/login/ui/login_form.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 {
|
class LoginPage extends HookConsumerWidget {
|
||||||
const LoginPage({Key? key}) : super(key: key);
|
const LoginPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return const Scaffold(
|
final appVersion = useState('0.0.0');
|
||||||
body: LoginForm(),
|
|
||||||
|
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:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/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/album_viewer_page.dart';
|
||||||
import 'package:immich_mobile/modules/album/views/asset_selection_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/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_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/select_user_for_sharing_page.dart';
|
||||||
import 'package:immich_mobile/modules/album/views/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/modules/settings/views/settings_page.dart';
|
||||||
import 'package:immich_mobile/routing/auth_guard.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/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.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/splash_screen.dart';
|
||||||
import 'package:immich_mobile/shared/views/tab_controller_page.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:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
@@ -80,6 +81,10 @@ part 'router.gr.dart';
|
|||||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
),
|
),
|
||||||
AutoRoute(page: SettingsPage, guards: [AuthGuard]),
|
AutoRoute(page: SettingsPage, guards: [AuthGuard]),
|
||||||
|
CustomRoute(
|
||||||
|
page: AppLogPage,
|
||||||
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppRouter extends _$AppRouter {
|
class AppRouter extends _$AppRouter {
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const SettingsPage());
|
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) {
|
HomeRoute.name: (routeData) {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const HomePage());
|
routeData: routeData, child: const HomePage());
|
||||||
@@ -218,7 +226,8 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
RouteConfig(FailedBackupStatusRoute.name,
|
RouteConfig(FailedBackupStatusRoute.name,
|
||||||
path: '/failed-backup-status-page', guards: [authGuard]),
|
path: '/failed-backup-status-page', guards: [authGuard]),
|
||||||
RouteConfig(SettingsRoute.name,
|
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';
|
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
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [HomePage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
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 'dart:collection';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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.service.dart';
|
||||||
import 'package:immich_mobile/modules/home/services/asset_cache.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/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class AssetNotifier extends StateNotifier<List<Asset>> {
|
class AssetNotifier extends StateNotifier<List<Asset>> {
|
||||||
final AssetService _assetService;
|
final AssetService _assetService;
|
||||||
final AssetCacheService _assetCacheService;
|
final AssetCacheService _assetCacheService;
|
||||||
|
final log = Logger('AssetNotifier');
|
||||||
final DeviceInfoService _deviceInfoService = DeviceInfoService();
|
final DeviceInfoService _deviceInfoService = DeviceInfoService();
|
||||||
bool _getAllAssetInProgress = false;
|
bool _getAllAssetInProgress = false;
|
||||||
bool _deleteInProgress = false;
|
bool _deleteInProgress = false;
|
||||||
@@ -33,32 +35,61 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
|
|||||||
final stopwatch = Stopwatch();
|
final stopwatch = Stopwatch();
|
||||||
try {
|
try {
|
||||||
_getAllAssetInProgress = true;
|
_getAllAssetInProgress = true;
|
||||||
|
|
||||||
final bool isCacheValid = await _assetCacheService.isValid();
|
final bool isCacheValid = await _assetCacheService.isValid();
|
||||||
|
stopwatch.start();
|
||||||
|
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
|
||||||
|
final remoteTask = _assetService.getRemoteAssets(hasCache: isCacheValid);
|
||||||
if (isCacheValid && state.isEmpty) {
|
if (isCacheValid && state.isEmpty) {
|
||||||
stopwatch.start();
|
|
||||||
state = await _assetCacheService.get();
|
state = await _assetCacheService.get();
|
||||||
debugPrint(
|
log.info(
|
||||||
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
|
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
|
||||||
);
|
);
|
||||||
stopwatch.reset();
|
stopwatch.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
stopwatch.start();
|
int remoteBegin = state.indexWhere((a) => a.isRemote);
|
||||||
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
|
remoteBegin = remoteBegin == -1 ? state.length : remoteBegin;
|
||||||
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
|
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();
|
stopwatch.reset();
|
||||||
|
if (newRemote == null &&
|
||||||
state = allAssets;
|
(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 {
|
} finally {
|
||||||
_getAllAssetInProgress = false;
|
_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();
|
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() {
|
clearAllAsset() {
|
||||||
@@ -124,8 +155,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
|
|||||||
if (local.isNotEmpty) {
|
if (local.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
return await PhotoManager.editor.deleteWithIds(local);
|
return await PhotoManager.editor.deleteWithIds(local);
|
||||||
} catch (e) {
|
} catch (e, stack) {
|
||||||
debugPrint("Delete asset from device failed: $e");
|
log.severe("Failed to delete asset from device", e, stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class ReleaseInfoNotifier extends StateNotifier<String> {
|
class ReleaseInfoNotifier extends StateNotifier<String> {
|
||||||
ReleaseInfoNotifier() : super("");
|
ReleaseInfoNotifier() : super("");
|
||||||
|
final log = Logger('ReleaseInfoNotifier');
|
||||||
void checkGithubReleaseInfo() async {
|
void checkGithubReleaseInfo() async {
|
||||||
final Client client = Client();
|
final Client client = Client();
|
||||||
var box = Hive.box(hiveGithubReleaseInfoBox);
|
var box = Hive.box(hiveGithubReleaseInfoBox);
|
||||||
@@ -28,9 +29,6 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
|
|||||||
String latestTagVersion = data["tag_name"];
|
String latestTagVersion = data["tag_name"];
|
||||||
state = latestTagVersion;
|
state = latestTagVersion;
|
||||||
|
|
||||||
debugPrint("Local release version $localReleaseVersion");
|
|
||||||
debugPrint("Remote release veresion $latestTagVersion");
|
|
||||||
|
|
||||||
if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
|
if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
|
||||||
VersionAnnouncementOverlayController.appLoader.show();
|
VersionAnnouncementOverlayController.appLoader.show();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,23 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:socket_io_client/socket_io_client.dart';
|
import 'package:socket_io_client/socket_io_client.dart';
|
||||||
|
|
||||||
class WebscoketState {
|
class WebsocketState {
|
||||||
final Socket? socket;
|
final Socket? socket;
|
||||||
final bool isConnected;
|
final bool isConnected;
|
||||||
|
|
||||||
WebscoketState({
|
WebsocketState({
|
||||||
this.socket,
|
this.socket,
|
||||||
required this.isConnected,
|
required this.isConnected,
|
||||||
});
|
});
|
||||||
|
|
||||||
WebscoketState copyWith({
|
WebsocketState copyWith({
|
||||||
Socket? socket,
|
Socket? socket,
|
||||||
bool? isConnected,
|
bool? isConnected,
|
||||||
}) {
|
}) {
|
||||||
return WebscoketState(
|
return WebsocketState(
|
||||||
socket: socket ?? this.socket,
|
socket: socket ?? this.socket,
|
||||||
isConnected: isConnected ?? this.isConnected,
|
isConnected: isConnected ?? this.isConnected,
|
||||||
);
|
);
|
||||||
@@ -30,13 +31,13 @@ class WebscoketState {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'WebscoketState(socket: $socket, isConnected: $isConnected)';
|
'WebsocketState(socket: $socket, isConnected: $isConnected)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
return other is WebscoketState &&
|
return other is WebsocketState &&
|
||||||
other.socket == socket &&
|
other.socket == socket &&
|
||||||
other.isConnected == isConnected;
|
other.isConnected == isConnected;
|
||||||
}
|
}
|
||||||
@@ -45,12 +46,11 @@ class WebscoketState {
|
|||||||
int get hashCode => socket.hashCode ^ isConnected.hashCode;
|
int get hashCode => socket.hashCode ^ isConnected.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||||
WebsocketNotifier(this.ref)
|
WebsocketNotifier(this.ref)
|
||||||
: super(WebscoketState(socket: null, isConnected: false)) {
|
: super(WebsocketState(socket: null, isConnected: false));
|
||||||
debugPrint("Init websocket instance");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
final log = Logger('WebsocketNotifier');
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
@@ -60,8 +60,8 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
|||||||
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
|
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||||
try {
|
try {
|
||||||
debugPrint("[WEBSOCKET] Attempting to connect to ws");
|
debugPrint("Attempting to connect to websocket");
|
||||||
// Configure socket transports must be sepecified
|
// Configure socket transports must be specified
|
||||||
Socket socket = io(
|
Socket socket = io(
|
||||||
endpoint.toString().replaceAll('/api', ''),
|
endpoint.toString().replaceAll('/api', ''),
|
||||||
OptionBuilder()
|
OptionBuilder()
|
||||||
@@ -76,18 +76,18 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
socket.onConnect((_) {
|
socket.onConnect((_) {
|
||||||
debugPrint("[WEBSOCKET] Established Websocket Connection");
|
debugPrint("Established Websocket Connection");
|
||||||
state = WebscoketState(isConnected: true, socket: socket);
|
state = WebsocketState(isConnected: true, socket: socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.onDisconnect((_) {
|
socket.onDisconnect((_) {
|
||||||
debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
|
debugPrint("Disconnect to Websocket Connection");
|
||||||
state = WebscoketState(isConnected: false, socket: null);
|
state = WebsocketState(isConnected: false, socket: null);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (errorMessage) {
|
socket.on('error', (errorMessage) {
|
||||||
debugPrint("Webcoket Error - $errorMessage");
|
log.severe("Websocket Error - $errorMessage");
|
||||||
state = WebscoketState(isConnected: false, socket: null);
|
state = WebsocketState(isConnected: false, socket: null);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('on_upload_success', (data) {
|
socket.on('on_upload_success', (data) {
|
||||||
@@ -105,21 +105,22 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
debugPrint("[WEBSOCKET] Attempting to disconnect");
|
debugPrint("Attempting to disconnect from websocket");
|
||||||
|
|
||||||
var socket = state.socket?.disconnect();
|
var socket = state.socket?.disconnect();
|
||||||
|
|
||||||
if (socket?.disconnected == true) {
|
if (socket?.disconnected == true) {
|
||||||
state = WebscoketState(isConnected: false, socket: null);
|
state = WebsocketState(isConnected: false, socket: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopListenToEvent(String eventName) {
|
stopListenToEvent(String eventName) {
|
||||||
debugPrint("[Websocket] Stop listening to event $eventName");
|
debugPrint("Stop listening to event $eventName");
|
||||||
state.socket?.off(eventName);
|
state.socket?.off(eventName);
|
||||||
}
|
}
|
||||||
|
|
||||||
listenUploadEvent() {
|
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) {
|
state.socket?.on('on_upload_success', (data) {
|
||||||
var jsonString = jsonDecode(data.toString());
|
var jsonString = jsonDecode(data.toString());
|
||||||
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
|
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
|
||||||
@@ -132,6 +133,6 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final websocketProvider =
|
final websocketProvider =
|
||||||
StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
|
StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
|
||||||
return WebsocketNotifier(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 {
|
Future<void> invalidate() async {
|
||||||
final file = await _getCacheFile();
|
try {
|
||||||
await file.delete();
|
final file = await _getCacheFile();
|
||||||
|
await file.delete();
|
||||||
|
} on FileSystemException {
|
||||||
|
// file is already deleted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> putRawData(dynamic data) async {
|
Future<void> putRawData(dynamic data) async {
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ class ImmichLoadingIndicator extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(15),
|
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) {
|
String formatBytes(int bytes) {
|
||||||
if (bytes < 1000) {
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
|
||||||
return "$bytes B";
|
|
||||||
} else if (bytes < 1000000) {
|
int magnitude = 0;
|
||||||
final kb = (bytes / 1000).toStringAsFixed(1);
|
double remainder = bytes.toDouble();
|
||||||
return "$kb kB";
|
while (remainder >= 1024) {
|
||||||
} else if (bytes < 1000000000) {
|
if (magnitude + 1 < units.length) {
|
||||||
final mb = (bytes / 1000000).toStringAsFixed(1);
|
magnitude++;
|
||||||
return "$mb MB";
|
remainder /= 1024;
|
||||||
} else {
|
}
|
||||||
final gb = (bytes / 1000000000).toStringAsFixed(1);
|
else {
|
||||||
return "$gb GB";
|
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)
|
[[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**
|
# **getAllAssets**
|
||||||
> List<AssetResponseDto> getAllAssets()
|
> List<AssetResponseDto> getAllAssets(ifNoneMatch)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -291,9 +291,10 @@ import 'package:openapi/api.dart';
|
|||||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
final api_instance = AssetApi();
|
final api_instance = AssetApi();
|
||||||
|
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = api_instance.getAllAssets();
|
final result = api_instance.getAllAssets(ifNoneMatch);
|
||||||
print(result);
|
print(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Exception when calling AssetApi->getAllAssets: $e\n');
|
print('Exception when calling AssetApi->getAllAssets: $e\n');
|
||||||
@@ -301,7 +302,10 @@ try {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Parameters
|
### 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
|
### Return type
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ Name | Type | Description | Notes
|
|||||||
**mimeType** | **String** | |
|
**mimeType** | **String** | |
|
||||||
**duration** | **String** | |
|
**duration** | **String** | |
|
||||||
**webpPath** | **String** | |
|
**webpPath** | **String** | |
|
||||||
**encodedVideoPath** | **String** | |
|
**encodedVideoPath** | **String** | | [optional]
|
||||||
**exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional]
|
**exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional]
|
||||||
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.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)
|
[[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** | |
|
**profileImagePath** | **String** | |
|
||||||
**shouldChangePassword** | **bool** | |
|
**shouldChangePassword** | **bool** | |
|
||||||
**isAdmin** | **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)
|
[[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
|
/// Get all AssetEntity belong to the user
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// 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
|
// ignore: prefer_const_declarations
|
||||||
final path = r'/asset';
|
final path = r'/asset';
|
||||||
|
|
||||||
@@ -308,6 +313,10 @@ class AssetApi {
|
|||||||
final headerParams = <String, String>{};
|
final headerParams = <String, String>{};
|
||||||
final formParams = <String, String>{};
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (ifNoneMatch != null) {
|
||||||
|
headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
|
||||||
|
}
|
||||||
|
|
||||||
const contentTypes = <String>[];
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
@@ -325,8 +334,13 @@ class AssetApi {
|
|||||||
///
|
///
|
||||||
///
|
///
|
||||||
/// Get all AssetEntity belong to the user
|
/// 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) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,48 +43,51 @@ class AlbumResponseDto {
|
|||||||
List<AssetResponseDto> assets;
|
List<AssetResponseDto> assets;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
|
bool operator ==(Object other) =>
|
||||||
other.assetCount == assetCount &&
|
identical(this, other) ||
|
||||||
other.id == id &&
|
other is AlbumResponseDto &&
|
||||||
other.ownerId == ownerId &&
|
other.assetCount == assetCount &&
|
||||||
other.albumName == albumName &&
|
other.id == id &&
|
||||||
other.createdAt == createdAt &&
|
other.ownerId == ownerId &&
|
||||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
other.albumName == albumName &&
|
||||||
other.shared == shared &&
|
other.createdAt == createdAt &&
|
||||||
other.sharedUsers == sharedUsers &&
|
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||||
other.assets == assets;
|
other.shared == shared &&
|
||||||
|
other.sharedUsers == sharedUsers &&
|
||||||
|
other.assets == assets;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(assetCount.hashCode) +
|
(assetCount.hashCode) +
|
||||||
(id.hashCode) +
|
(id.hashCode) +
|
||||||
(ownerId.hashCode) +
|
(ownerId.hashCode) +
|
||||||
(albumName.hashCode) +
|
(albumName.hashCode) +
|
||||||
(createdAt.hashCode) +
|
(createdAt.hashCode) +
|
||||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||||
(shared.hashCode) +
|
(shared.hashCode) +
|
||||||
(sharedUsers.hashCode) +
|
(sharedUsers.hashCode) +
|
||||||
(assets.hashCode);
|
(assets.hashCode);
|
||||||
|
|
||||||
@override
|
@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() {
|
Map<String, dynamic> toJson() {
|
||||||
final _json = <String, dynamic>{};
|
final _json = <String, dynamic>{};
|
||||||
_json[r'assetCount'] = assetCount;
|
_json[r'assetCount'] = assetCount;
|
||||||
_json[r'id'] = id;
|
_json[r'id'] = id;
|
||||||
_json[r'ownerId'] = ownerId;
|
_json[r'ownerId'] = ownerId;
|
||||||
_json[r'albumName'] = albumName;
|
_json[r'albumName'] = albumName;
|
||||||
_json[r'createdAt'] = createdAt;
|
_json[r'createdAt'] = createdAt;
|
||||||
if (albumThumbnailAssetId != null) {
|
if (albumThumbnailAssetId != null) {
|
||||||
_json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
|
_json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
|
||||||
} else {
|
} else {
|
||||||
_json[r'albumThumbnailAssetId'] = null;
|
_json[r'albumThumbnailAssetId'] = null;
|
||||||
}
|
}
|
||||||
_json[r'shared'] = shared;
|
_json[r'shared'] = shared;
|
||||||
_json[r'sharedUsers'] = sharedUsers;
|
_json[r'sharedUsers'] = sharedUsers;
|
||||||
_json[r'assets'] = assets;
|
_json[r'assets'] = assets;
|
||||||
return _json;
|
return _json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +101,13 @@ class AlbumResponseDto {
|
|||||||
// Ensure that the map contains the required keys.
|
// Ensure that the map contains the required keys.
|
||||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
// Note 2: this code is stripped in release mode!
|
// Note 2: this code is stripped in release mode!
|
||||||
assert(() {
|
// assert(() {
|
||||||
requiredKeys.forEach((key) {
|
// requiredKeys.forEach((key) {
|
||||||
assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
|
// 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.');
|
// assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
|
||||||
});
|
// });
|
||||||
return true;
|
// return true;
|
||||||
}());
|
// }());
|
||||||
|
|
||||||
return AlbumResponseDto(
|
return AlbumResponseDto(
|
||||||
assetCount: mapValueOfType<int>(json, r'assetCount')!,
|
assetCount: mapValueOfType<int>(json, r'assetCount')!,
|
||||||
@@ -112,7 +115,8 @@ class AlbumResponseDto {
|
|||||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||||
albumName: mapValueOfType<String>(json, r'albumName')!,
|
albumName: mapValueOfType<String>(json, r'albumName')!,
|
||||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||||
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
albumThumbnailAssetId:
|
||||||
|
mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||||
shared: mapValueOfType<bool>(json, r'shared')!,
|
shared: mapValueOfType<bool>(json, r'shared')!,
|
||||||
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
|
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
|
||||||
assets: AssetResponseDto.listFromJson(json[r'assets'])!,
|
assets: AssetResponseDto.listFromJson(json[r'assets'])!,
|
||||||
@@ -121,7 +125,10 @@ class AlbumResponseDto {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
static List<AlbumResponseDto>? listFromJson(
|
||||||
|
dynamic json, {
|
||||||
|
bool growable = false,
|
||||||
|
}) {
|
||||||
final result = <AlbumResponseDto>[];
|
final result = <AlbumResponseDto>[];
|
||||||
if (json is List && json.isNotEmpty) {
|
if (json is List && json.isNotEmpty) {
|
||||||
for (final row in json) {
|
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
|
// 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>>{};
|
final map = <String, List<AlbumResponseDto>>{};
|
||||||
if (json is Map && json.isNotEmpty) {
|
if (json is Map && json.isNotEmpty) {
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
for (final entry in json.entries) {
|
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) {
|
if (value != null) {
|
||||||
map[entry.key] = value;
|
map[entry.key] = value;
|
||||||
}
|
}
|
||||||
@@ -176,4 +189,3 @@ class AlbumResponseDto {
|
|||||||
'assets',
|
'assets',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ class AssetResponseDto {
|
|||||||
required this.mimeType,
|
required this.mimeType,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
required this.webpPath,
|
required this.webpPath,
|
||||||
required this.encodedVideoPath,
|
this.encodedVideoPath,
|
||||||
this.exifInfo,
|
this.exifInfo,
|
||||||
this.smartInfo,
|
this.smartInfo,
|
||||||
required this.livePhotoVideoId,
|
this.livePhotoVideoId,
|
||||||
});
|
});
|
||||||
|
|
||||||
AssetTypeEnum type;
|
AssetTypeEnum type;
|
||||||
@@ -79,74 +79,71 @@ class AssetResponseDto {
|
|||||||
String? livePhotoVideoId;
|
String? livePhotoVideoId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||||
identical(this, other) ||
|
other.type == type &&
|
||||||
other is AssetResponseDto &&
|
other.id == id &&
|
||||||
other.type == type &&
|
other.deviceAssetId == deviceAssetId &&
|
||||||
other.id == id &&
|
other.ownerId == ownerId &&
|
||||||
other.deviceAssetId == deviceAssetId &&
|
other.deviceId == deviceId &&
|
||||||
other.ownerId == ownerId &&
|
other.originalPath == originalPath &&
|
||||||
other.deviceId == deviceId &&
|
other.resizePath == resizePath &&
|
||||||
other.originalPath == originalPath &&
|
other.createdAt == createdAt &&
|
||||||
other.resizePath == resizePath &&
|
other.modifiedAt == modifiedAt &&
|
||||||
other.createdAt == createdAt &&
|
other.isFavorite == isFavorite &&
|
||||||
other.modifiedAt == modifiedAt &&
|
other.mimeType == mimeType &&
|
||||||
other.isFavorite == isFavorite &&
|
other.duration == duration &&
|
||||||
other.mimeType == mimeType &&
|
other.webpPath == webpPath &&
|
||||||
other.duration == duration &&
|
other.encodedVideoPath == encodedVideoPath &&
|
||||||
other.webpPath == webpPath &&
|
other.exifInfo == exifInfo &&
|
||||||
other.encodedVideoPath == encodedVideoPath &&
|
other.smartInfo == smartInfo &&
|
||||||
other.exifInfo == exifInfo &&
|
other.livePhotoVideoId == livePhotoVideoId;
|
||||||
other.smartInfo == smartInfo &&
|
|
||||||
other.livePhotoVideoId == livePhotoVideoId;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(type.hashCode) +
|
(type.hashCode) +
|
||||||
(id.hashCode) +
|
(id.hashCode) +
|
||||||
(deviceAssetId.hashCode) +
|
(deviceAssetId.hashCode) +
|
||||||
(ownerId.hashCode) +
|
(ownerId.hashCode) +
|
||||||
(deviceId.hashCode) +
|
(deviceId.hashCode) +
|
||||||
(originalPath.hashCode) +
|
(originalPath.hashCode) +
|
||||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||||
(createdAt.hashCode) +
|
(createdAt.hashCode) +
|
||||||
(modifiedAt.hashCode) +
|
(modifiedAt.hashCode) +
|
||||||
(isFavorite.hashCode) +
|
(isFavorite.hashCode) +
|
||||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||||
(duration.hashCode) +
|
(duration.hashCode) +
|
||||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
|
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
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]';
|
||||||
'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() {
|
Map<String, dynamic> toJson() {
|
||||||
final _json = <String, dynamic>{};
|
final _json = <String, dynamic>{};
|
||||||
_json[r'type'] = type;
|
_json[r'type'] = type;
|
||||||
_json[r'id'] = id;
|
_json[r'id'] = id;
|
||||||
_json[r'deviceAssetId'] = deviceAssetId;
|
_json[r'deviceAssetId'] = deviceAssetId;
|
||||||
_json[r'ownerId'] = ownerId;
|
_json[r'ownerId'] = ownerId;
|
||||||
_json[r'deviceId'] = deviceId;
|
_json[r'deviceId'] = deviceId;
|
||||||
_json[r'originalPath'] = originalPath;
|
_json[r'originalPath'] = originalPath;
|
||||||
if (resizePath != null) {
|
if (resizePath != null) {
|
||||||
_json[r'resizePath'] = resizePath;
|
_json[r'resizePath'] = resizePath;
|
||||||
} else {
|
} else {
|
||||||
_json[r'resizePath'] = null;
|
_json[r'resizePath'] = null;
|
||||||
}
|
}
|
||||||
_json[r'createdAt'] = createdAt;
|
_json[r'createdAt'] = createdAt;
|
||||||
_json[r'modifiedAt'] = modifiedAt;
|
_json[r'modifiedAt'] = modifiedAt;
|
||||||
_json[r'isFavorite'] = isFavorite;
|
_json[r'isFavorite'] = isFavorite;
|
||||||
if (mimeType != null) {
|
if (mimeType != null) {
|
||||||
_json[r'mimeType'] = mimeType;
|
_json[r'mimeType'] = mimeType;
|
||||||
} else {
|
} else {
|
||||||
_json[r'mimeType'] = null;
|
_json[r'mimeType'] = null;
|
||||||
}
|
}
|
||||||
_json[r'duration'] = duration;
|
_json[r'duration'] = duration;
|
||||||
if (webpPath != null) {
|
if (webpPath != null) {
|
||||||
_json[r'webpPath'] = webpPath;
|
_json[r'webpPath'] = webpPath;
|
||||||
} else {
|
} else {
|
||||||
@@ -185,13 +182,13 @@ class AssetResponseDto {
|
|||||||
// Ensure that the map contains the required keys.
|
// Ensure that the map contains the required keys.
|
||||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
// Note 2: this code is stripped in release mode!
|
// Note 2: this code is stripped in release mode!
|
||||||
// assert(() {
|
assert(() {
|
||||||
// requiredKeys.forEach((key) {
|
requiredKeys.forEach((key) {
|
||||||
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
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.');
|
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||||
// });
|
});
|
||||||
// return true;
|
return true;
|
||||||
// }());
|
}());
|
||||||
|
|
||||||
return AssetResponseDto(
|
return AssetResponseDto(
|
||||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||||
@@ -216,10 +213,7 @@ class AssetResponseDto {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<AssetResponseDto>? listFromJson(
|
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
dynamic json, {
|
|
||||||
bool growable = false,
|
|
||||||
}) {
|
|
||||||
final result = <AssetResponseDto>[];
|
final result = <AssetResponseDto>[];
|
||||||
if (json is List && json.isNotEmpty) {
|
if (json is List && json.isNotEmpty) {
|
||||||
for (final row in json) {
|
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
|
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
|
||||||
static Map<String, List<AssetResponseDto>> mapListFromJson(
|
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
dynamic json, {
|
|
||||||
bool growable = false,
|
|
||||||
}) {
|
|
||||||
final map = <String, List<AssetResponseDto>>{};
|
final map = <String, List<AssetResponseDto>>{};
|
||||||
if (json is Map && json.isNotEmpty) {
|
if (json is Map && json.isNotEmpty) {
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
for (final entry in json.entries) {
|
for (final entry in json.entries) {
|
||||||
final value = AssetResponseDto.listFromJson(
|
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
entry.value,
|
|
||||||
growable: growable,
|
|
||||||
);
|
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
map[entry.key] = value;
|
map[entry.key] = value;
|
||||||
}
|
}
|
||||||
@@ -282,7 +270,6 @@ class AssetResponseDto {
|
|||||||
'mimeType',
|
'mimeType',
|
||||||
'duration',
|
'duration',
|
||||||
'webpPath',
|
'webpPath',
|
||||||
'encodedVideoPath',
|
|
||||||
'livePhotoVideoId',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class UserResponseDto {
|
|||||||
required this.profileImagePath,
|
required this.profileImagePath,
|
||||||
required this.shouldChangePassword,
|
required this.shouldChangePassword,
|
||||||
required this.isAdmin,
|
required this.isAdmin,
|
||||||
required this.deletedAt,
|
this.deletedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
String id;
|
String id;
|
||||||
@@ -40,49 +40,52 @@ class UserResponseDto {
|
|||||||
|
|
||||||
bool isAdmin;
|
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;
|
DateTime? deletedAt;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
|
||||||
identical(this, other) ||
|
other.id == id &&
|
||||||
other is UserResponseDto &&
|
other.email == email &&
|
||||||
other.id == id &&
|
other.firstName == firstName &&
|
||||||
other.email == email &&
|
other.lastName == lastName &&
|
||||||
other.firstName == firstName &&
|
other.createdAt == createdAt &&
|
||||||
other.lastName == lastName &&
|
other.profileImagePath == profileImagePath &&
|
||||||
other.createdAt == createdAt &&
|
other.shouldChangePassword == shouldChangePassword &&
|
||||||
other.profileImagePath == profileImagePath &&
|
other.isAdmin == isAdmin &&
|
||||||
other.shouldChangePassword == shouldChangePassword &&
|
other.deletedAt == deletedAt;
|
||||||
other.isAdmin == isAdmin &&
|
|
||||||
other.deletedAt == deletedAt;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(id.hashCode) +
|
(id.hashCode) +
|
||||||
(email.hashCode) +
|
(email.hashCode) +
|
||||||
(firstName.hashCode) +
|
(firstName.hashCode) +
|
||||||
(lastName.hashCode) +
|
(lastName.hashCode) +
|
||||||
(createdAt.hashCode) +
|
(createdAt.hashCode) +
|
||||||
(profileImagePath.hashCode) +
|
(profileImagePath.hashCode) +
|
||||||
(shouldChangePassword.hashCode) +
|
(shouldChangePassword.hashCode) +
|
||||||
(isAdmin.hashCode) +
|
(isAdmin.hashCode) +
|
||||||
(deletedAt == null ? 0 : deletedAt!.hashCode);
|
(deletedAt == null ? 0 : deletedAt!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
|
||||||
'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final _json = <String, dynamic>{};
|
final _json = <String, dynamic>{};
|
||||||
_json[r'id'] = id;
|
_json[r'id'] = id;
|
||||||
_json[r'email'] = email;
|
_json[r'email'] = email;
|
||||||
_json[r'firstName'] = firstName;
|
_json[r'firstName'] = firstName;
|
||||||
_json[r'lastName'] = lastName;
|
_json[r'lastName'] = lastName;
|
||||||
_json[r'createdAt'] = createdAt;
|
_json[r'createdAt'] = createdAt;
|
||||||
_json[r'profileImagePath'] = profileImagePath;
|
_json[r'profileImagePath'] = profileImagePath;
|
||||||
_json[r'shouldChangePassword'] = shouldChangePassword;
|
_json[r'shouldChangePassword'] = shouldChangePassword;
|
||||||
_json[r'isAdmin'] = isAdmin;
|
_json[r'isAdmin'] = isAdmin;
|
||||||
if (deletedAt != null) {
|
if (deletedAt != null) {
|
||||||
_json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
|
_json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
|
||||||
} else {
|
} else {
|
||||||
@@ -101,13 +104,13 @@ class UserResponseDto {
|
|||||||
// Ensure that the map contains the required keys.
|
// Ensure that the map contains the required keys.
|
||||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
// Note 2: this code is stripped in release mode!
|
// Note 2: this code is stripped in release mode!
|
||||||
// assert(() {
|
assert(() {
|
||||||
// requiredKeys.forEach((key) {
|
requiredKeys.forEach((key) {
|
||||||
// assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
|
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.');
|
assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
|
||||||
// });
|
});
|
||||||
// return true;
|
return true;
|
||||||
// }());
|
}());
|
||||||
|
|
||||||
return UserResponseDto(
|
return UserResponseDto(
|
||||||
id: mapValueOfType<String>(json, r'id')!,
|
id: mapValueOfType<String>(json, r'id')!,
|
||||||
@@ -116,8 +119,7 @@ class UserResponseDto {
|
|||||||
lastName: mapValueOfType<String>(json, r'lastName')!,
|
lastName: mapValueOfType<String>(json, r'lastName')!,
|
||||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||||
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
|
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
|
||||||
shouldChangePassword:
|
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
|
||||||
mapValueOfType<bool>(json, r'shouldChangePassword')!,
|
|
||||||
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
|
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
|
||||||
deletedAt: mapDateTime(json, r'deletedAt', ''),
|
deletedAt: mapDateTime(json, r'deletedAt', ''),
|
||||||
);
|
);
|
||||||
@@ -125,10 +127,7 @@ class UserResponseDto {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<UserResponseDto>? listFromJson(
|
static List<UserResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
dynamic json, {
|
|
||||||
bool growable = false,
|
|
||||||
}) {
|
|
||||||
final result = <UserResponseDto>[];
|
final result = <UserResponseDto>[];
|
||||||
if (json is List && json.isNotEmpty) {
|
if (json is List && json.isNotEmpty) {
|
||||||
for (final row in json) {
|
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
|
// maps a json object with a list of UserResponseDto-objects as value to a dart map
|
||||||
static Map<String, List<UserResponseDto>> mapListFromJson(
|
static Map<String, List<UserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
dynamic json, {
|
|
||||||
bool growable = false,
|
|
||||||
}) {
|
|
||||||
final map = <String, List<UserResponseDto>>{};
|
final map = <String, List<UserResponseDto>>{};
|
||||||
if (json is Map && json.isNotEmpty) {
|
if (json is Map && json.isNotEmpty) {
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
for (final entry in json.entries) {
|
for (final entry in json.entries) {
|
||||||
final value = UserResponseDto.listFromJson(
|
final value = UserResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
entry.value,
|
|
||||||
growable: growable,
|
|
||||||
);
|
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
map[entry.key] = value;
|
map[entry.key] = value;
|
||||||
}
|
}
|
||||||
@@ -186,6 +179,6 @@ class UserResponseDto {
|
|||||||
'profileImagePath',
|
'profileImagePath',
|
||||||
'shouldChangePassword',
|
'shouldChangePassword',
|
||||||
'isAdmin',
|
'isAdmin',
|
||||||
'deletedAt',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ packages:
|
|||||||
name: ffi
|
name: ffi
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "2.0.1"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -554,12 +554,12 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: logging
|
name: logging
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.1.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -629,7 +629,7 @@ packages:
|
|||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.2"
|
version: "1.4.3+1"
|
||||||
package_info_plus_linux:
|
package_info_plus_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -664,7 +664,7 @@ packages:
|
|||||||
name: package_info_plus_windows
|
name: package_info_plus_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "2.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -699,7 +699,7 @@ packages:
|
|||||||
name: path_provider_linux
|
name: path_provider_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.6"
|
version: "2.1.7"
|
||||||
path_provider_macos:
|
path_provider_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -720,7 +720,7 @@ packages:
|
|||||||
name: path_provider_windows
|
name: path_provider_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.6"
|
version: "2.1.3"
|
||||||
pedantic:
|
pedantic:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -998,14 +998,14 @@ packages:
|
|||||||
name: sqflite
|
name: sqflite
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2+1"
|
version: "2.2.0+3"
|
||||||
sqflite_common:
|
sqflite_common:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_common
|
name: sqflite_common
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1+1"
|
version: "2.4.0+2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1257,7 +1257,7 @@ packages:
|
|||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.2"
|
version: "2.7.0"
|
||||||
wkt_parser:
|
wkt_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.36.1+56
|
version: 1.37.0+58
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
@@ -47,6 +47,7 @@ dependencies:
|
|||||||
|
|
||||||
# easy to remove packages:
|
# 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?
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -71,7 +72,9 @@ flutter:
|
|||||||
- family: SnowburstOne
|
- family: SnowburstOne
|
||||||
fonts:
|
fonts:
|
||||||
- asset: fonts/SnowburstOne.ttf
|
- asset: fonts/SnowburstOne.ttf
|
||||||
|
- family: Inconsolata
|
||||||
|
fonts:
|
||||||
|
- asset: fonts/Inconsolata-Regular.ttf
|
||||||
flutter_icons:
|
flutter_icons:
|
||||||
image_path_android: "assets/immich-logo-no-outline.png"
|
image_path_android: "assets/immich-logo-no-outline.png"
|
||||||
image_path_ios: "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 { assetUploadOption } from '../../config/asset-upload.config';
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { ServeFileDto } from './dto/serve-file.dto';
|
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 { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-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 { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||||
import { AssetResponseDto } from './response-dto/asset-response.dto';
|
import { AssetResponseDto } from './response-dto/asset-response.dto';
|
||||||
@@ -108,7 +108,7 @@ export class AssetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('/file/:assetId')
|
@Get('/file/:assetId')
|
||||||
@Header('Cache-Control', 'max-age=300')
|
@Header('Cache-Control', 'max-age=3600')
|
||||||
async serveFile(
|
async serveFile(
|
||||||
@Headers() headers: Record<string, string>,
|
@Headers() headers: Record<string, string>,
|
||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
@@ -119,13 +119,14 @@ export class AssetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('/thumbnail/:assetId')
|
@Get('/thumbnail/:assetId')
|
||||||
@Header('Cache-Control', 'max-age=300')
|
@Header('Cache-Control', 'max-age=3600')
|
||||||
async getAssetThumbnail(
|
async getAssetThumbnail(
|
||||||
|
@Headers() headers: Record<string, string>,
|
||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
@Param('assetId') assetId: string,
|
@Param('assetId') assetId: string,
|
||||||
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
|
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return this.assetService.getAssetThumbnail(assetId, query, res);
|
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/curated-objects')
|
@Get('/curated-objects')
|
||||||
@@ -168,8 +169,15 @@ export class AssetController {
|
|||||||
* Get all AssetEntity belong to the user
|
* Get all AssetEntity belong to the user
|
||||||
*/
|
*/
|
||||||
@Get('/')
|
@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[]> {
|
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')
|
@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;
|
let fileReadStream: ReadStream;
|
||||||
|
|
||||||
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
|
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
|
||||||
@@ -316,28 +321,22 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (!asset.resizePath) {
|
||||||
throw new NotFoundException('resizePath not set');
|
throw new NotFoundException('resizePath not set');
|
||||||
}
|
}
|
||||||
|
if (await processETag(asset.resizePath, res, headers)) {
|
||||||
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
await fs.access(asset.resizePath, constants.R_OK);
|
||||||
|
fileReadStream = createReadStream(asset.resizePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.header('Cache-Control', 'max-age=300');
|
|
||||||
return new StreamableFile(fileReadStream);
|
return new StreamableFile(fileReadStream);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.header('Cache-Control', 'none');
|
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;
|
let fileReadStream: ReadStream;
|
||||||
const asset = await this._assetRepository.getById(assetId);
|
const asset = await this._assetRepository.getById(assetId);
|
||||||
|
|
||||||
@@ -371,6 +370,9 @@ export class AssetService {
|
|||||||
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
|
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
|
||||||
throw new InternalServerErrorException(`Failed to serve 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);
|
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||||
fileReadStream = createReadStream(asset.resizePath);
|
fileReadStream = createReadStream(asset.resizePath);
|
||||||
|
|
||||||
@@ -384,7 +386,9 @@ export class AssetService {
|
|||||||
res.set({
|
res.set({
|
||||||
'Content-Type': asset.mimeType,
|
'Content-Type': asset.mimeType,
|
||||||
});
|
});
|
||||||
|
if (await processETag(asset.originalPath, res, headers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
|
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
|
||||||
fileReadStream = createReadStream(asset.originalPath);
|
fileReadStream = createReadStream(asset.originalPath);
|
||||||
} else {
|
} else {
|
||||||
@@ -392,7 +396,9 @@ export class AssetService {
|
|||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/webp',
|
'Content-Type': 'image/webp',
|
||||||
});
|
});
|
||||||
|
if (await processETag(asset.webpPath, res, headers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
|
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
|
||||||
fileReadStream = createReadStream(asset.webpPath);
|
fileReadStream = createReadStream(asset.webpPath);
|
||||||
} else {
|
} else {
|
||||||
@@ -403,6 +409,9 @@ export class AssetService {
|
|||||||
if (!asset.resizePath) {
|
if (!asset.resizePath) {
|
||||||
throw new Error('resizePath not set');
|
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);
|
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||||
fileReadStream = createReadStream(asset.resizePath);
|
fileReadStream = createReadStream(asset.resizePath);
|
||||||
@@ -436,9 +445,9 @@ export class AssetService {
|
|||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
/** Extracting Start and End value from Range Header */
|
/** Extracting Start and End value from Range Header */
|
||||||
let [start, end] = range.replace(/bytes=/, '').split('-');
|
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||||
start = parseInt(start, 10);
|
let start = parseInt(startStr, 10);
|
||||||
end = end ? parseInt(end, 10) : size - 1;
|
let end = endStr ? parseInt(endStr, 10) : size - 1;
|
||||||
|
|
||||||
if (!isNaN(start) && isNaN(end)) {
|
if (!isNaN(start) && isNaN(end)) {
|
||||||
start = start;
|
start = start;
|
||||||
@@ -475,7 +484,9 @@ export class AssetService {
|
|||||||
res.set({
|
res.set({
|
||||||
'Content-Type': mimeType,
|
'Content-Type': mimeType,
|
||||||
});
|
});
|
||||||
|
if (await processETag(asset.originalPath, res, headers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return new StreamableFile(createReadStream(videoPath));
|
return new StreamableFile(createReadStream(videoPath));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -632,3 +643,14 @@ export class AssetService {
|
|||||||
return this._assetRepository.getAssetCountByUserId(authUser.id);
|
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;
|
mimeType!: string | null;
|
||||||
duration!: string;
|
duration!: string;
|
||||||
webpPath!: string | null;
|
webpPath!: string | null;
|
||||||
encodedVideoPath!: string | null;
|
encodedVideoPath?: string | null;
|
||||||
exifInfo?: ExifResponseDto;
|
exifInfo?: ExifResponseDto;
|
||||||
smartInfo?: SmartInfoResponseDto;
|
smartInfo?: SmartInfoResponseDto;
|
||||||
livePhotoVideoId!: string | null;
|
livePhotoVideoId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class UserResponseDto {
|
|||||||
profileImagePath!: string;
|
profileImagePath!: string;
|
||||||
shouldChangePassword!: boolean;
|
shouldChangePassword!: boolean;
|
||||||
isAdmin!: boolean;
|
isAdmin!: boolean;
|
||||||
deletedAt!: Date | null;
|
deletedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapUser(entity: UserEntity): UserResponseDto {
|
export function mapUser(entity: UserEntity): UserResponseDto {
|
||||||
@@ -22,6 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
|
|||||||
profileImagePath: entity.profileImagePath,
|
profileImagePath: entity.profileImagePath,
|
||||||
shouldChangePassword: entity.shouldChangePassword,
|
shouldChangePassword: entity.shouldChangePassword,
|
||||||
isAdmin: entity.isAdmin,
|
isAdmin: entity.isAdmin,
|
||||||
deletedAt: entity.deletedAt || null,
|
deletedAt: entity.deletedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,5 +127,16 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
expect(result).rejects.toBeInstanceOf(NotFoundException);
|
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) {
|
if (!user) {
|
||||||
throw new BadRequestException('User not found');
|
throw new BadRequestException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.isAdmin) {
|
||||||
|
throw new BadRequestException('Cannot delete admin user');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedUser = await this.userRepository.delete(user);
|
const deletedUser = await this.userRepository.delete(user);
|
||||||
return mapUser(deletedUser);
|
return mapUser(deletedUser);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
|||||||
|
|
||||||
export const serverVersion: IServerVersion = {
|
export const serverVersion: IServerVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 36,
|
minor: 37,
|
||||||
patch: 2,
|
patch: 0,
|
||||||
build: 56,
|
build: 58,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
app.set('trust proxy');
|
app.set('trust proxy');
|
||||||
|
app.set('etag', 'strong');
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(json({ limit: '10mb' }));
|
app.use(json({ limit: '10mb' }));
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class DownloadService {
|
|||||||
fileCount++;
|
fileCount++;
|
||||||
|
|
||||||
// for easier testing, can be changed before merging.
|
// for easier testing, can be changed before merging.
|
||||||
if (totalSize > HumanReadableSize.GB * 20) {
|
if (totalSize > HumanReadableSize.GiB * 20) {
|
||||||
complete = false;
|
complete = false;
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(
|
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
const KB = 1000;
|
const KiB = Math.pow(1024, 1);
|
||||||
const MB = KB * 1000;
|
const MiB = Math.pow(1024, 2);
|
||||||
const GB = MB * 1000;
|
const GiB = Math.pow(1024, 3);
|
||||||
const TB = GB * 1000;
|
const TiB = Math.pow(1024, 4);
|
||||||
const PB = TB * 1000;
|
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) {
|
export function asHumanReadable(bytes: number, precision = 1): string {
|
||||||
if (bytes >= PB) {
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
|
||||||
return `${(bytes / PB).toFixed(precision)}PB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bytes >= TB) {
|
let magnitude = 0;
|
||||||
return `${(bytes / TB).toFixed(precision)}TB`;
|
let remainder = bytes;
|
||||||
}
|
while (remainder >= 1024) {
|
||||||
|
if (magnitude + 1 < units.length) {
|
||||||
|
magnitude++;
|
||||||
|
remainder /= 1024;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bytes >= GB) {
|
return `${remainder.toFixed( magnitude == 0 ? 0 : precision )} ${units[magnitude]}`;
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -427,7 +427,7 @@ export interface AssetResponseDto {
|
|||||||
* @type {string}
|
* @type {string}
|
||||||
* @memberof AssetResponseDto
|
* @memberof AssetResponseDto
|
||||||
*/
|
*/
|
||||||
'encodedVideoPath': string | null;
|
'encodedVideoPath'?: string | null;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {ExifResponseDto}
|
* @type {ExifResponseDto}
|
||||||
@@ -445,7 +445,7 @@ export interface AssetResponseDto {
|
|||||||
* @type {string}
|
* @type {string}
|
||||||
* @memberof AssetResponseDto
|
* @memberof AssetResponseDto
|
||||||
*/
|
*/
|
||||||
'livePhotoVideoId': string | null;
|
'livePhotoVideoId'?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -1729,7 +1729,7 @@ export interface UserResponseDto {
|
|||||||
* @type {string}
|
* @type {string}
|
||||||
* @memberof UserResponseDto
|
* @memberof UserResponseDto
|
||||||
*/
|
*/
|
||||||
'deletedAt': string | null;
|
'deletedAt'?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -2788,10 +2788,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
|||||||
/**
|
/**
|
||||||
* Get all AssetEntity belong to the user
|
* Get all AssetEntity belong to the user
|
||||||
* @summary
|
* @summary
|
||||||
|
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
getAllAssets: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
getAllAssets: async (ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
const localVarPath = `/asset`;
|
const localVarPath = `/asset`;
|
||||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
@@ -2808,6 +2809,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
|||||||
// http bearer authentication required
|
// http bearer authentication required
|
||||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
if (ifNoneMatch !== undefined && ifNoneMatch !== null) {
|
||||||
|
localVarHeaderParameter['if-none-match'] = String(ifNoneMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
@@ -3388,11 +3393,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
|||||||
/**
|
/**
|
||||||
* Get all AssetEntity belong to the user
|
* Get all AssetEntity belong to the user
|
||||||
* @summary
|
* @summary
|
||||||
|
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
async getAllAssets(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
async getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(ifNoneMatch, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
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
|
* Get all AssetEntity belong to the user
|
||||||
* @summary
|
* @summary
|
||||||
|
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
getAllAssets(options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
getAllAssets(ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
||||||
return localVarFp.getAllAssets(options).then((request) => request(axios, basePath));
|
return localVarFp.getAllAssets(ifNoneMatch, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Get a single asset\'s information
|
* Get a single asset\'s information
|
||||||
@@ -3788,12 +3795,13 @@ export class AssetApi extends BaseAPI {
|
|||||||
/**
|
/**
|
||||||
* Get all AssetEntity belong to the user
|
* Get all AssetEntity belong to the user
|
||||||
* @summary
|
* @summary
|
||||||
|
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
* @memberof AssetApi
|
* @memberof AssetApi
|
||||||
*/
|
*/
|
||||||
public getAllAssets(options?: AxiosRequestConfig) {
|
public getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig) {
|
||||||
return AssetApiFp(this.configuration).getAllAssets(options).then((request) => request(this.axios, this.basePath));
|
return AssetApiFp(this.configuration).getAllAssets(ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
$: spaceUnit = stats.usage.slice(stats.usage.length - 2, stats.usage.length);
|
$: spaceUnit = stats.usage.split(' ')[1];
|
||||||
$: spaceUsage = stats.usage.slice(0, stats.usage.length - 2);
|
$: spaceUsage = stats.usage.split(' ')[0];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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>
|
<div>
|
||||||
<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />
|
<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
||||||
|
import { getHumanReadableBytes } from '../../utils/byte-units';
|
||||||
|
|
||||||
type Leaflet = typeof import('leaflet');
|
type Leaflet = typeof import('leaflet');
|
||||||
type LeafletMap = import('leaflet').Map;
|
type LeafletMap = import('leaflet').Map;
|
||||||
@@ -59,32 +60,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
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 getMegapixel = (width: number, height: number): number | undefined => {
|
||||||
const megapixel = Math.round((height * width) / 1_000_000);
|
const megapixel = Math.round((height * width) / 1_000_000);
|
||||||
@@ -143,13 +118,13 @@
|
|||||||
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
|
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
|
||||||
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
|
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
|
||||||
<p>
|
<p>
|
||||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}MP
|
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p>
|
<p>{getHumanReadableBytes(asset.exifInfo.fileSizeInByte)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +137,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
|
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
|
||||||
<div class="flex text-sm gap-2">
|
<div class="flex text-sm gap-2">
|
||||||
<p>{`f/${asset.exifInfo.fNumber}` || ''}</p>
|
<p>{`ƒ/${asset.exifInfo.fNumber}` || ''}</p>
|
||||||
|
|
||||||
{#if asset.exifInfo.exposureTime}
|
{#if asset.exifInfo.exposureTime}
|
||||||
<p>{`1/${Math.floor(1 / asset.exifInfo.exposureTime)}`}</p>
|
<p>{`1/${Math.floor(1 / asset.exifInfo.exposureTime)}`}</p>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="immich-scrubbable-scrollbar"
|
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:width={isDragging ? '100vw' : '60px'}
|
||||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||||
on:mouseenter={() => (isHover = true)}
|
on:mouseenter={() => (isHover = true)}
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
>
|
>
|
||||||
{#if isHover}
|
{#if isHover}
|
||||||
<div
|
<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'}
|
style:top={currentMouseYLocation + 'px'}
|
||||||
>
|
>
|
||||||
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||||
import { notificationController, NotificationType } from './notification/notification';
|
import { notificationController, NotificationType } from './notification/notification';
|
||||||
|
import { getHumanReadableBytes } from '../../utils/byte-units';
|
||||||
|
|
||||||
let showDetail = true;
|
let showDetail = true;
|
||||||
|
|
||||||
let uploadLength = 0;
|
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
|
// Reactive action to get thumbnail image of upload asset whenever there is a new one added to the list
|
||||||
$: {
|
$: {
|
||||||
if ($uploadAssetsStore.length != uploadLength) {
|
if ($uploadAssetsStore.length != uploadLength) {
|
||||||
@@ -140,7 +115,7 @@
|
|||||||
<input
|
<input
|
||||||
disabled
|
disabled
|
||||||
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
|
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
|
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