diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 0adacb6494..5e072b84d4 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -17,7 +17,7 @@ "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", - "backup_album_selection_page_select_albums": "Select Albums", + "backup_album_selection_page_select_albums": "Select albums", "backup_album_selection_page_selection_info": "Selection Info", "backup_album_selection_page_total_assets": "Total unique assets", "backup_all": "All", diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index 5530b37ddb..87d2f45d59 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_card.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_card.dart @@ -21,8 +21,39 @@ class AlbumThumbnailCard extends StatelessWidget { @override Widget build(BuildContext context) { var box = Hive.box(userInfoBox); + var cardSize = MediaQuery.of(context).size.width / 2 - 18; + var isDarkMode = Theme.of(context).brightness == Brightness.dark; - final cardSize = MediaQuery.of(context).size.width / 2 - 18; + buildEmptyThumbnail() { + return Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + ), + child: SizedBox( + height: cardSize, + width: cardSize, + child: const Center( + child: Icon(Icons.no_photography), + ), + ), + ); + } + + buildAlbumThumbnail() { + return CachedNetworkImage( + memCacheHeight: max(400, cardSize.toInt() * 3), + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 200), + imageUrl: getAlbumThumbnailUrl( + album, + type: ThumbnailFormat.JPEG, + ), + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + cacheKey: "${album.albumThumbnailAssetId}", + ); + } return GestureDetector( onTap: () { @@ -35,19 +66,9 @@ class AlbumThumbnailCard extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - memCacheHeight: max(400, cardSize.toInt() * 3), - width: cardSize, - height: cardSize, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 200), - imageUrl: - getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG), - httpHeaders: { - "Authorization": "Bearer ${box.get(accessTokenKey)}" - }, - cacheKey: "${album.albumThumbnailAssetId}", - ), + child: album.albumThumbnailAssetId == null + ? buildEmptyThumbnail() + : buildAlbumThumbnail(), ), Padding( padding: const EdgeInsets.only(top: 8.0), diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 65f0eba624..0b3a61cc4c 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:cancellation_token_http/http.dart'; +import 'package:flutter/widgets.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; @@ -68,6 +69,7 @@ class BackupNotifier extends StateNotifier { final AuthenticationState _authState; final BackgroundService _backgroundService; final Ref ref; + var isGettingBackupInfo = false; /// /// UI INTERACTION @@ -172,9 +174,10 @@ class BackupNotifier extends StateNotifier { /// Get all album on the device /// Get all selected and excluded album from the user's persistent storage /// If this is the first time performing backup - set the default selected album to be - /// the one that has all assets (Recent on Android, Recents on iOS) + /// the one that has all assets (`Recent` on Android, `Recents` on iOS) /// Future _getBackupAlbumsInfo() async { + Stopwatch stopwatch = Stopwatch()..start(); // Get all albums on the device List availableAlbums = []; List albums = await PhotoManager.getAssetPathList( @@ -182,6 +185,8 @@ class BackupNotifier extends StateNotifier { type: RequestType.common, ); + log.info('Found ${albums.length} local albums'); + for (AssetPathEntity album in albums) { AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); @@ -293,6 +298,8 @@ class BackupNotifier extends StateNotifier { } catch (e, stackTrace) { log.severe("Failed to generate album from id", e, stackTrace); } + + debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms"); } /// @@ -364,25 +371,29 @@ class BackupNotifier extends StateNotifier { return; } - /// /// Get all necessary information for calculating the available albums, /// which albums are selected or excluded /// and then update the UI according to those information - /// Future getBackupInfo() async { - final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled(); - state = state.copyWith(backgroundBackup: isEnabled); - if (state.backupProgress != BackUpProgressEnum.inBackground) { - await _getBackupAlbumsInfo(); - await _updateServerInfo(); - await _updateBackupAssetCount(); + if (!isGettingBackupInfo) { + isGettingBackupInfo = true; + + var isEnabled = await _backgroundService.isBackgroundBackupEnabled(); + + state = state.copyWith(backgroundBackup: isEnabled); + + if (state.backupProgress != BackUpProgressEnum.inBackground) { + await _getBackupAlbumsInfo(); + await _updateServerInfo(); + await _updateBackupAssetCount(); + } + + isGettingBackupInfo = false; } } - /// /// Save user selection of selected albums and excluded albums to /// Hive database - /// void _updatePersistentAlbumsSelection() { final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); Box backupAlbumInfoBox = @@ -402,9 +413,7 @@ class BackupNotifier extends StateNotifier { ); } - /// /// Invoke backup process - /// Future startBackupProcess() async { assert(state.backupProgress == BackUpProgressEnum.idle); state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); diff --git a/mobile/lib/modules/backup/views/album_preview_page.dart b/mobile/lib/modules/backup/views/album_preview_page.dart index c236934794..27ca79082b 100644 --- a/mobile/lib/modules/backup/views/album_preview_page.dart +++ b/mobile/lib/modules/backup/views/album_preview_page.dart @@ -36,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget { title: Column( children: [ Text( - "${album.name} (${album.assetCountAsync})", + album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), ), Padding( diff --git a/mobile/lib/modules/backup/views/backup_album_selection_page.dart b/mobile/lib/modules/backup/views/backup_album_selection_page.dart index c94552ca3f..3de7294742 100644 --- a/mobile/lib/modules/backup/views/backup_album_selection_page.dart +++ b/mobile/lib/modules/backup/views/backup_album_selection_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/ui/album_info_card.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; @@ -14,10 +15,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { const BackupAlbumSelectionPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - final availableAlbums = ref.watch(backupProvider).availableAlbums; + // final availableAlbums = ref.watch(backupProvider).availableAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + final albums = useState>( + ref.watch(backupProvider).availableAlbums, + ); useEffect( () { @@ -28,7 +32,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ); buildAlbumSelectionList() { - if (availableAlbums.isEmpty) { + if (albums.value.isEmpty) { return const Center( child: ImmichLoadingIndicator(), ); @@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { height: 265, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: availableAlbums.length, + itemCount: albums.value.length, physics: const BouncingScrollPhysics(), itemBuilder: ((context, index) { - var thumbnailData = availableAlbums[index].thumbnailData; + var thumbnailData = albums.value[index].thumbnailData; return Padding( padding: index == 0 ? const EdgeInsets.only(left: 16.00) : const EdgeInsets.all(0), child: AlbumInfoCard( imageData: thumbnailData, - albumInfo: availableAlbums[index], + albumInfo: albums.value[index], ), ); }), @@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { child: Chip( visualDensity: VisualDensity.compact, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(10), ), label: Text( album.name, style: TextStyle( fontSize: 10, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.black - : Colors.white, + color: isDarkTheme ? Colors.black : Colors.white, fontWeight: FontWeight.bold, ), ), @@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { child: Chip( visualDensity: VisualDensity.compact, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(10), ), label: Text( album.name, @@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { }).toSet(); } + buildSearchBar() { + return Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0), + child: TextFormField( + onChanged: (searchValue) { + albums.value = ref + .watch(backupProvider) + .availableAlbums + .where( + (album) => album.name + .toLowerCase() + .contains(searchValue.toLowerCase()), + ) + .toList(); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 8.0, + ), + hintText: "Search", + hintStyle: TextStyle( + color: isDarkTheme ? Colors.white : Colors.grey, + fontSize: 14.0, + ), + prefixIcon: const Icon( + Icons.search, + color: Colors.grey, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200], + ), + ), + ); + } + return Scaffold( appBar: AppBar( leading: IconButton( @@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { child: Card( margin: const EdgeInsets.all(0), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(10), side: BorderSide( color: isDarkTheme ? const Color.fromARGB(255, 0, 0, 0) @@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ListTile( title: Text( - "backup_album_selection_page_albums_device" - .tr(args: [availableAlbums.length.toString()]), + "backup_album_selection_page_albums_device".tr( + args: [ + ref.watch(backupProvider).availableAlbums.length.toString() + ], + ), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), subtitle: Padding( @@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { builder: (BuildContext context) { return AlertDialog( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), ), elevation: 5, title: Text( @@ -284,6 +329,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ), ), + buildSearchBar(), + Padding( padding: const EdgeInsets.only(bottom: 16.0), child: buildAlbumSelectionList(), diff --git a/mobile/lib/modules/home/services/asset.service.dart b/mobile/lib/modules/home/services/asset.service.dart index 2d8014484d..0599d0c547 100644 --- a/mobile/lib/modules/home/services/asset.service.dart +++ b/mobile/lib/modules/home/services/asset.service.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/utils/openapi_extensions.dart'; import 'package:immich_mobile/utils/tuple.dart'; +import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final assetServiceProvider = Provider( @@ -26,20 +27,26 @@ class AssetService { final ApiService _apiService; final BackupService _backupService; final BackgroundService _backgroundService; + final log = Logger('AssetService'); AssetService(this._apiService, this._backupService, this._backgroundService); /// Returns `null` if the server state did not change, else list of assets Future?> getRemoteAssets({required bool hasCache}) async { - final Box box = Hive.box(userInfoBox); - final Pair, String?>? remote = await _apiService - .assetApi - .getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null); - if (remote == null) { + try { + final Box box = Hive.box(userInfoBox); + final Pair, String?>? remote = await _apiService + .assetApi + .getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null); + if (remote == null) { + return null; + } + box.put(assetEtagKey, remote.second); + return remote.first.map(Asset.remote).toList(growable: false); + } catch (e, stack) { + log.severe('Error while getting remote assets', e, stack); return null; } - box.put(assetEtagKey, remote.second); - return remote.first.map(Asset.remote).toList(growable: false); } /// if [urgent] is `true`, do not block by waiting on the background service diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index 63761c5783..aeac61c9dc 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -32,7 +32,9 @@ class ImmichSliverAppBar extends ConsumerWidget { snap: false, backgroundColor: Theme.of(context).appBarTheme.backgroundColor, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(5)), + borderRadius: BorderRadius.all( + Radius.circular(5), + ), ), leading: Builder( builder: (BuildContext context) { diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 5b2fbbb416..1fafc63436 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -20,6 +22,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/services/share.service.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:openapi/api.dart'; @@ -37,6 +40,8 @@ class HomePage extends HookConsumerWidget { final albums = ref.watch(albumProvider); final albumService = ref.watch(albumServiceProvider); + final tipOneOpacity = useState(0.0); + useEffect( () { ref.read(websocketProvider.notifier).connect(); @@ -146,6 +151,49 @@ class HomePage extends HookConsumerWidget { } } + buildLoadingIndicator() { + Timer(const Duration(seconds: 2), () { + tipOneOpacity.value = 1; + }); + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const ImmichLoadingIndicator(), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text( + 'Building the timeline', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Theme.of(context).primaryColor, + ), + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: tipOneOpacity.value, + child: const SizedBox( + width: 250, + child: Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + 'If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).', + textAlign: TextAlign.justify, + style: TextStyle( + fontSize: 12, + ), + ), + ), + ), + ) + ], + ), + ); + } + return SafeArea( bottom: !multiselectEnabled.state, top: true, @@ -164,15 +212,17 @@ class HomePage extends HookConsumerWidget { top: selectionEnabledHook.value ? 0 : 60, bottom: 0.0, ), - child: ImmichAssetGrid( - renderList: renderList, - assetsPerRow: - appSettingService.getSetting(AppSettingsEnum.tilesPerRow), - showStorageIndicator: appSettingService - .getSetting(AppSettingsEnum.storageIndicator), - listener: selectionListener, - selectionActive: selectionEnabledHook.value, - ), + child: ref.watch(assetProvider).isEmpty + ? buildLoadingIndicator() + : ImmichAssetGrid( + renderList: renderList, + assetsPerRow: appSettingService + .getSetting(AppSettingsEnum.tilesPerRow), + showStorageIndicator: appSettingService + .getSetting(AppSettingsEnum.storageIndicator), + listener: selectionListener, + selectionActive: selectionEnabledHook.value, + ), ), if (selectionEnabledHook.value) ControlBottomAppBar( diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 6a0b72a0b8..13f53b8ff1 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -83,6 +83,13 @@ class LoginForm extends HookConsumerWidget { [], ); + populateTestLoginInfo() { + usernameController.text = 'testuser@email.com'; + passwordController.text = 'password'; + serverEndpointController.text = 'http://10.1.15.216:2283/api'; + isSaveLoginInfo.value = true; + } + return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 300), @@ -92,10 +99,13 @@ class LoginForm extends HookConsumerWidget { runSpacing: 16, alignment: WrapAlignment.center, children: [ - const Image( - image: AssetImage('assets/immich-logo-no-outline.png'), - width: 100, - filterQuality: FilterQuality.high, + GestureDetector( + onDoubleTap: () => populateTestLoginInfo(), + child: const Image( + image: AssetImage('assets/immich-logo-no-outline.png'), + width: 100, + filterQuality: FilterQuality.high, + ), ), Text( 'IMMICH', diff --git a/mobile/lib/shared/ui/immich_loading_indicator.dart b/mobile/lib/shared/ui/immich_loading_indicator.dart index 84a33ba046..bd4ad0d3cb 100644 --- a/mobile/lib/shared/ui/immich_loading_indicator.dart +++ b/mobile/lib/shared/ui/immich_loading_indicator.dart @@ -15,7 +15,10 @@ class ImmichLoadingIndicator extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), padding: const EdgeInsets.all(15), - child: const CircularProgressIndicator(color: Colors.white), + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), ); } } diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index ac170523d8..f9901a358f 100644 Binary files a/mobile/openapi/lib/model/album_response_dto.dart and b/mobile/openapi/lib/model/album_response_dto.dart differ