mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	chore(mobile) Improve mobile UI (#1038)
This commit is contained in:
		@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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),
 | 
			
		||||
 
 | 
			
		||||
@@ -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<BackUpState> {
 | 
			
		||||
  final AuthenticationState _authState;
 | 
			
		||||
  final BackgroundService _backgroundService;
 | 
			
		||||
  final Ref ref;
 | 
			
		||||
  var isGettingBackupInfo = false;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// UI INTERACTION
 | 
			
		||||
@@ -172,9 +174,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
  /// Get all album on the device
 | 
			
		||||
  /// Get all selected and excluded album from the user's persistent storage
 | 
			
		||||
  /// If this is the first time performing backup - set the default selected album to be
 | 
			
		||||
  /// the one that has all assets (Recent on Android, Recents on iOS)
 | 
			
		||||
  /// the one that has all assets (`Recent` on Android, `Recents` on iOS)
 | 
			
		||||
  ///
 | 
			
		||||
  Future<void> _getBackupAlbumsInfo() async {
 | 
			
		||||
    Stopwatch stopwatch = Stopwatch()..start();
 | 
			
		||||
    // Get all albums on the device
 | 
			
		||||
    List<AvailableAlbum> availableAlbums = [];
 | 
			
		||||
    List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
 | 
			
		||||
@@ -182,6 +185,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
      type: RequestType.common,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    log.info('Found ${albums.length} local albums');
 | 
			
		||||
 | 
			
		||||
    for (AssetPathEntity album in albums) {
 | 
			
		||||
      AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
 | 
			
		||||
 | 
			
		||||
@@ -293,6 +298,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
    } 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<BackUpState> {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Get all necessary information for calculating the available albums,
 | 
			
		||||
  /// which albums are selected or excluded
 | 
			
		||||
  /// and then update the UI according to those information
 | 
			
		||||
  ///
 | 
			
		||||
  Future<void> getBackupInfo() async {
 | 
			
		||||
    final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
 | 
			
		||||
    state = state.copyWith(backgroundBackup: isEnabled);
 | 
			
		||||
    if (state.backupProgress != BackUpProgressEnum.inBackground) {
 | 
			
		||||
      await _getBackupAlbumsInfo();
 | 
			
		||||
      await _updateServerInfo();
 | 
			
		||||
      await _updateBackupAssetCount();
 | 
			
		||||
    if (!isGettingBackupInfo) {
 | 
			
		||||
      isGettingBackupInfo = true;
 | 
			
		||||
 | 
			
		||||
      var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
 | 
			
		||||
 | 
			
		||||
      state = state.copyWith(backgroundBackup: isEnabled);
 | 
			
		||||
 | 
			
		||||
      if (state.backupProgress != BackUpProgressEnum.inBackground) {
 | 
			
		||||
        await _getBackupAlbumsInfo();
 | 
			
		||||
        await _updateServerInfo();
 | 
			
		||||
        await _updateBackupAssetCount();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      isGettingBackupInfo = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Save user selection of selected albums and excluded albums to
 | 
			
		||||
  /// Hive database
 | 
			
		||||
  ///
 | 
			
		||||
  void _updatePersistentAlbumsSelection() {
 | 
			
		||||
    final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
 | 
			
		||||
    Box<HiveBackupAlbums> backupAlbumInfoBox =
 | 
			
		||||
@@ -402,9 +413,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Invoke backup process
 | 
			
		||||
  ///
 | 
			
		||||
  Future<void> startBackupProcess() async {
 | 
			
		||||
    assert(state.backupProgress == BackUpProgressEnum.idle);
 | 
			
		||||
    state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
 | 
			
		||||
        title: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
            Text(
 | 
			
		||||
              "${album.name} (${album.assetCountAsync})",
 | 
			
		||||
              album.name,
 | 
			
		||||
              style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/immich_colors.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 | 
			
		||||
@@ -14,10 +15,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
  const BackupAlbumSelectionPage({Key? key}) : super(key: key);
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final availableAlbums = ref.watch(backupProvider).availableAlbums;
 | 
			
		||||
    // final availableAlbums = ref.watch(backupProvider).availableAlbums;
 | 
			
		||||
    final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
 | 
			
		||||
    final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
 | 
			
		||||
    final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
 | 
			
		||||
    final albums = useState<List<AvailableAlbum>>(
 | 
			
		||||
      ref.watch(backupProvider).availableAlbums,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
@@ -28,7 +32,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    buildAlbumSelectionList() {
 | 
			
		||||
      if (availableAlbums.isEmpty) {
 | 
			
		||||
      if (albums.value.isEmpty) {
 | 
			
		||||
        return const Center(
 | 
			
		||||
          child: ImmichLoadingIndicator(),
 | 
			
		||||
        );
 | 
			
		||||
@@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
        height: 265,
 | 
			
		||||
        child: ListView.builder(
 | 
			
		||||
          scrollDirection: Axis.horizontal,
 | 
			
		||||
          itemCount: availableAlbums.length,
 | 
			
		||||
          itemCount: albums.value.length,
 | 
			
		||||
          physics: const BouncingScrollPhysics(),
 | 
			
		||||
          itemBuilder: ((context, index) {
 | 
			
		||||
            var thumbnailData = availableAlbums[index].thumbnailData;
 | 
			
		||||
            var thumbnailData = albums.value[index].thumbnailData;
 | 
			
		||||
            return Padding(
 | 
			
		||||
              padding: index == 0
 | 
			
		||||
                  ? const EdgeInsets.only(left: 16.00)
 | 
			
		||||
                  : const EdgeInsets.all(0),
 | 
			
		||||
              child: AlbumInfoCard(
 | 
			
		||||
                imageData: thumbnailData,
 | 
			
		||||
                albumInfo: availableAlbums[index],
 | 
			
		||||
                albumInfo: albums.value[index],
 | 
			
		||||
              ),
 | 
			
		||||
            );
 | 
			
		||||
          }),
 | 
			
		||||
@@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
            child: Chip(
 | 
			
		||||
              visualDensity: VisualDensity.compact,
 | 
			
		||||
              shape: RoundedRectangleBorder(
 | 
			
		||||
                borderRadius: BorderRadius.circular(5),
 | 
			
		||||
                borderRadius: BorderRadius.circular(10),
 | 
			
		||||
              ),
 | 
			
		||||
              label: Text(
 | 
			
		||||
                album.name,
 | 
			
		||||
                style: TextStyle(
 | 
			
		||||
                  fontSize: 10,
 | 
			
		||||
                  color: Theme.of(context).brightness == Brightness.dark
 | 
			
		||||
                      ? Colors.black
 | 
			
		||||
                      : Colors.white,
 | 
			
		||||
                  color: isDarkTheme ? Colors.black : Colors.white,
 | 
			
		||||
                  fontWeight: FontWeight.bold,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
@@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
            child: Chip(
 | 
			
		||||
              visualDensity: VisualDensity.compact,
 | 
			
		||||
              shape: RoundedRectangleBorder(
 | 
			
		||||
                borderRadius: BorderRadius.circular(5),
 | 
			
		||||
                borderRadius: BorderRadius.circular(10),
 | 
			
		||||
              ),
 | 
			
		||||
              label: Text(
 | 
			
		||||
                album.name,
 | 
			
		||||
@@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
      }).toSet();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildSearchBar() {
 | 
			
		||||
      return Padding(
 | 
			
		||||
        padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
 | 
			
		||||
        child: TextFormField(
 | 
			
		||||
          onChanged: (searchValue) {
 | 
			
		||||
            albums.value = ref
 | 
			
		||||
                .watch(backupProvider)
 | 
			
		||||
                .availableAlbums
 | 
			
		||||
                .where(
 | 
			
		||||
                  (album) => album.name
 | 
			
		||||
                      .toLowerCase()
 | 
			
		||||
                      .contains(searchValue.toLowerCase()),
 | 
			
		||||
                )
 | 
			
		||||
                .toList();
 | 
			
		||||
          },
 | 
			
		||||
          decoration: InputDecoration(
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
              horizontal: 8.0,
 | 
			
		||||
              vertical: 8.0,
 | 
			
		||||
            ),
 | 
			
		||||
            hintText: "Search",
 | 
			
		||||
            hintStyle: TextStyle(
 | 
			
		||||
              color: isDarkTheme ? Colors.white : Colors.grey,
 | 
			
		||||
              fontSize: 14.0,
 | 
			
		||||
            ),
 | 
			
		||||
            prefixIcon: const Icon(
 | 
			
		||||
              Icons.search,
 | 
			
		||||
              color: Colors.grey,
 | 
			
		||||
            ),
 | 
			
		||||
            border: OutlineInputBorder(
 | 
			
		||||
              borderRadius: BorderRadius.circular(10),
 | 
			
		||||
              borderSide: BorderSide.none,
 | 
			
		||||
            ),
 | 
			
		||||
            filled: true,
 | 
			
		||||
            fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: IconButton(
 | 
			
		||||
@@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
            child: Card(
 | 
			
		||||
              margin: const EdgeInsets.all(0),
 | 
			
		||||
              shape: RoundedRectangleBorder(
 | 
			
		||||
                borderRadius: BorderRadius.circular(5),
 | 
			
		||||
                borderRadius: BorderRadius.circular(10),
 | 
			
		||||
                side: BorderSide(
 | 
			
		||||
                  color: isDarkTheme
 | 
			
		||||
                      ? const Color.fromARGB(255, 0, 0, 0)
 | 
			
		||||
@@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
          ListTile(
 | 
			
		||||
            title: Text(
 | 
			
		||||
              "backup_album_selection_page_albums_device"
 | 
			
		||||
                  .tr(args: [availableAlbums.length.toString()]),
 | 
			
		||||
              "backup_album_selection_page_albums_device".tr(
 | 
			
		||||
                args: [
 | 
			
		||||
                  ref.watch(backupProvider).availableAlbums.length.toString()
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
 | 
			
		||||
            ),
 | 
			
		||||
            subtitle: Padding(
 | 
			
		||||
@@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
                  builder: (BuildContext context) {
 | 
			
		||||
                    return AlertDialog(
 | 
			
		||||
                      shape: RoundedRectangleBorder(
 | 
			
		||||
                        borderRadius: BorderRadius.circular(12),
 | 
			
		||||
                        borderRadius: BorderRadius.circular(10),
 | 
			
		||||
                      ),
 | 
			
		||||
                      elevation: 5,
 | 
			
		||||
                      title: Text(
 | 
			
		||||
@@ -284,6 +329,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          buildSearchBar(),
 | 
			
		||||
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.only(bottom: 16.0),
 | 
			
		||||
            child: buildAlbumSelectionList(),
 | 
			
		||||
 
 | 
			
		||||
@@ -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<List<Asset>?> getRemoteAssets({required bool hasCache}) async {
 | 
			
		||||
    final Box box = Hive.box(userInfoBox);
 | 
			
		||||
    final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
 | 
			
		||||
        .assetApi
 | 
			
		||||
        .getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null);
 | 
			
		||||
    if (remote == null) {
 | 
			
		||||
    try {
 | 
			
		||||
      final Box box = Hive.box(userInfoBox);
 | 
			
		||||
      final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
 | 
			
		||||
          .assetApi
 | 
			
		||||
          .getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null);
 | 
			
		||||
      if (remote == null) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      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
 | 
			
		||||
 
 | 
			
		||||
@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -43,48 +43,51 @@ class AlbumResponseDto {
 | 
			
		||||
  List<AssetResponseDto> assets;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
 | 
			
		||||
     other.assetCount == assetCount &&
 | 
			
		||||
     other.id == id &&
 | 
			
		||||
     other.ownerId == ownerId &&
 | 
			
		||||
     other.albumName == albumName &&
 | 
			
		||||
     other.createdAt == createdAt &&
 | 
			
		||||
     other.albumThumbnailAssetId == albumThumbnailAssetId &&
 | 
			
		||||
     other.shared == shared &&
 | 
			
		||||
     other.sharedUsers == sharedUsers &&
 | 
			
		||||
     other.assets == assets;
 | 
			
		||||
  bool operator ==(Object other) =>
 | 
			
		||||
      identical(this, other) ||
 | 
			
		||||
      other is AlbumResponseDto &&
 | 
			
		||||
          other.assetCount == assetCount &&
 | 
			
		||||
          other.id == id &&
 | 
			
		||||
          other.ownerId == ownerId &&
 | 
			
		||||
          other.albumName == albumName &&
 | 
			
		||||
          other.createdAt == createdAt &&
 | 
			
		||||
          other.albumThumbnailAssetId == albumThumbnailAssetId &&
 | 
			
		||||
          other.shared == shared &&
 | 
			
		||||
          other.sharedUsers == sharedUsers &&
 | 
			
		||||
          other.assets == assets;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (assetCount.hashCode) +
 | 
			
		||||
    (id.hashCode) +
 | 
			
		||||
    (ownerId.hashCode) +
 | 
			
		||||
    (albumName.hashCode) +
 | 
			
		||||
    (createdAt.hashCode) +
 | 
			
		||||
    (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
 | 
			
		||||
    (shared.hashCode) +
 | 
			
		||||
    (sharedUsers.hashCode) +
 | 
			
		||||
    (assets.hashCode);
 | 
			
		||||
      // ignore: unnecessary_parenthesis
 | 
			
		||||
      (assetCount.hashCode) +
 | 
			
		||||
      (id.hashCode) +
 | 
			
		||||
      (ownerId.hashCode) +
 | 
			
		||||
      (albumName.hashCode) +
 | 
			
		||||
      (createdAt.hashCode) +
 | 
			
		||||
      (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
 | 
			
		||||
      (shared.hashCode) +
 | 
			
		||||
      (sharedUsers.hashCode) +
 | 
			
		||||
      (assets.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
 | 
			
		||||
  String toString() =>
 | 
			
		||||
      'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final _json = <String, dynamic>{};
 | 
			
		||||
      _json[r'assetCount'] = assetCount;
 | 
			
		||||
      _json[r'id'] = id;
 | 
			
		||||
      _json[r'ownerId'] = ownerId;
 | 
			
		||||
      _json[r'albumName'] = albumName;
 | 
			
		||||
      _json[r'createdAt'] = createdAt;
 | 
			
		||||
    _json[r'assetCount'] = assetCount;
 | 
			
		||||
    _json[r'id'] = id;
 | 
			
		||||
    _json[r'ownerId'] = ownerId;
 | 
			
		||||
    _json[r'albumName'] = albumName;
 | 
			
		||||
    _json[r'createdAt'] = createdAt;
 | 
			
		||||
    if (albumThumbnailAssetId != null) {
 | 
			
		||||
      _json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
 | 
			
		||||
    } else {
 | 
			
		||||
      _json[r'albumThumbnailAssetId'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      _json[r'shared'] = shared;
 | 
			
		||||
      _json[r'sharedUsers'] = sharedUsers;
 | 
			
		||||
      _json[r'assets'] = assets;
 | 
			
		||||
    _json[r'shared'] = shared;
 | 
			
		||||
    _json[r'sharedUsers'] = sharedUsers;
 | 
			
		||||
    _json[r'assets'] = assets;
 | 
			
		||||
    return _json;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -98,13 +101,13 @@ class AlbumResponseDto {
 | 
			
		||||
      // Ensure that the map contains the required keys.
 | 
			
		||||
      // Note 1: the values aren't checked for validity beyond being non-null.
 | 
			
		||||
      // Note 2: this code is stripped in release mode!
 | 
			
		||||
      assert(() {
 | 
			
		||||
        requiredKeys.forEach((key) {
 | 
			
		||||
          assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
 | 
			
		||||
          assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
 | 
			
		||||
        });
 | 
			
		||||
        return true;
 | 
			
		||||
      }());
 | 
			
		||||
      // assert(() {
 | 
			
		||||
      //   requiredKeys.forEach((key) {
 | 
			
		||||
      //     assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
 | 
			
		||||
      //     assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
 | 
			
		||||
      //   });
 | 
			
		||||
      //   return true;
 | 
			
		||||
      // }());
 | 
			
		||||
 | 
			
		||||
      return AlbumResponseDto(
 | 
			
		||||
        assetCount: mapValueOfType<int>(json, r'assetCount')!,
 | 
			
		||||
@@ -112,7 +115,8 @@ class AlbumResponseDto {
 | 
			
		||||
        ownerId: mapValueOfType<String>(json, r'ownerId')!,
 | 
			
		||||
        albumName: mapValueOfType<String>(json, r'albumName')!,
 | 
			
		||||
        createdAt: mapValueOfType<String>(json, r'createdAt')!,
 | 
			
		||||
        albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
 | 
			
		||||
        albumThumbnailAssetId:
 | 
			
		||||
            mapValueOfType<String>(json, r'albumThumbnailAssetId'),
 | 
			
		||||
        shared: mapValueOfType<bool>(json, r'shared')!,
 | 
			
		||||
        sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
 | 
			
		||||
        assets: AssetResponseDto.listFromJson(json[r'assets'])!,
 | 
			
		||||
@@ -121,7 +125,10 @@ class AlbumResponseDto {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
  static List<AlbumResponseDto>? listFromJson(
 | 
			
		||||
    dynamic json, {
 | 
			
		||||
    bool growable = false,
 | 
			
		||||
  }) {
 | 
			
		||||
    final result = <AlbumResponseDto>[];
 | 
			
		||||
    if (json is List && json.isNotEmpty) {
 | 
			
		||||
      for (final row in json) {
 | 
			
		||||
@@ -149,12 +156,18 @@ class AlbumResponseDto {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // maps a json object with a list of AlbumResponseDto-objects as value to a dart map
 | 
			
		||||
  static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
  static Map<String, List<AlbumResponseDto>> mapListFromJson(
 | 
			
		||||
    dynamic json, {
 | 
			
		||||
    bool growable = false,
 | 
			
		||||
  }) {
 | 
			
		||||
    final map = <String, List<AlbumResponseDto>>{};
 | 
			
		||||
    if (json is Map && json.isNotEmpty) {
 | 
			
		||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
			
		||||
      for (final entry in json.entries) {
 | 
			
		||||
        final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
 | 
			
		||||
        final value = AlbumResponseDto.listFromJson(
 | 
			
		||||
          entry.value,
 | 
			
		||||
          growable: growable,
 | 
			
		||||
        );
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          map[entry.key] = value;
 | 
			
		||||
        }
 | 
			
		||||
@@ -176,4 +189,3 @@ class AlbumResponseDto {
 | 
			
		||||
    'assets',
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user