فهرست منبع

refactor(mobile): immich loading overlay (#5320)

* refactor: dcm fixes

* refactor: ImmichLoadingOverlay to custom hook

* chore: dart fixes

* pr changes

* fix: process overlay add / remove in postframecallback

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
shenlong 1 سال پیش
والد
کامیت
527d602a9f

+ 0 - 1
mobile/analysis_options.yaml

@@ -49,7 +49,6 @@ dart_code_metrics:
     # Common
     - avoid-accessing-collections-by-constant-index
     - avoid-accessing-other-classes-private-members
-    - avoid-async-call-in-sync-function
     - avoid-cascade-after-if-null
     - avoid-collapsible-if
     - avoid-collection-methods-with-unrelated-types

+ 1 - 1
mobile/integration_test/test_utils/general_helper.dart

@@ -45,7 +45,7 @@ class ImmichTestHelper {
     await tester.pumpWidget(
       ProviderScope(
         overrides: [dbProvider.overrideWithValue(db)],
-        child: app.getMainWidget(),
+        child: const app.MainWidget(),
       ),
     );
     // Post run tasks

+ 3 - 3
mobile/lib/constants/immich_colors.dart

@@ -1,5 +1,5 @@
 import 'package:flutter/material.dart';
 
-Color immichBackgroundColor = const Color(0xFFf6f8fe);
-Color immichDarkBackgroundColor = const Color.fromARGB(255, 0, 0, 0);
-Color immichDarkThemePrimaryColor = const Color.fromARGB(255, 173, 203, 250);
+const Color immichBackgroundColor = Color(0xFFf6f8fe);
+const Color immichDarkBackgroundColor = Color.fromARGB(255, 0, 0, 0);
+const Color immichDarkThemePrimaryColor = Color.fromARGB(255, 173, 203, 250);

+ 33 - 31
mobile/lib/main.dart

@@ -1,3 +1,4 @@
+import 'dart:async';
 import 'dart:io';
 
 import 'package:device_info_plus/device_info_plus.dart';
@@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_displaymode/flutter_displaymode.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:timezone/data/latest.dart';
 import 'package:immich_mobile/constants/locales.dart';
 import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
@@ -28,7 +30,6 @@ import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/immich_logger.service.dart';
 import 'package:immich_mobile/shared/services/local_notification.service.dart';
-import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
 import 'package:immich_mobile/utils/migration.dart';
@@ -43,10 +44,11 @@ void main() async {
   await initApp();
   await migrateDatabaseIfNeeded(db);
   HttpOverrides.global = HttpSSLCertOverride();
+
   runApp(
     ProviderScope(
       overrides: [dbProvider.overrideWithValue(db)],
-      child: getMainWidget(),
+      child: const MainWidget(),
     ),
   );
 }
@@ -108,16 +110,6 @@ Future<Isar> loadDb() async {
   return db;
 }
 
-Widget getMainWidget() {
-  return EasyLocalization(
-    supportedLocales: locales,
-    path: translationsPath,
-    useFallbackTranslations: true,
-    fallbackLocale: locales.first,
-    child: const ImmichApp(),
-  );
-}
-
 class ImmichApp extends ConsumerStatefulWidget {
   const ImmichApp({super.key});
 
@@ -167,10 +159,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
       // Android 8 does not support transparent app bars
       final info = await DeviceInfoPlugin().androidInfo;
       if (info.version.sdkInt <= 26) {
-        overlayStyle =
-            MediaQuery.of(context).platformBrightness == Brightness.light
-                ? SystemUiOverlayStyle.light
-                : SystemUiOverlayStyle.dark;
+        overlayStyle = context.isDarkTheme
+            ? SystemUiOverlayStyle.dark
+            : SystemUiOverlayStyle.light;
       }
     }
     SystemChrome.setSystemUIOverlayStyle(overlayStyle);
@@ -202,22 +193,33 @@ class ImmichAppState extends ConsumerState<ImmichApp>
       supportedLocales: context.supportedLocales,
       locale: context.locale,
       debugShowCheckedModeBanner: false,
-      home: Stack(
-        children: [
-          MaterialApp.router(
-            title: 'Immich',
-            debugShowCheckedModeBanner: false,
-            themeMode: ref.watch(immichThemeProvider),
-            darkTheme: immichDarkTheme,
-            theme: immichLightTheme,
-            routeInformationParser: router.defaultRouteParser(),
-            routerDelegate: router.delegate(
-              navigatorObservers: () => [TabNavigationObserver(ref: ref)],
-            ),
-          ),
-          const ImmichLoadingOverlay(),
-        ],
+      home: MaterialApp.router(
+        title: 'Immich',
+        debugShowCheckedModeBanner: false,
+        themeMode: ref.watch(immichThemeProvider),
+        darkTheme: immichDarkTheme,
+        theme: immichLightTheme,
+        routeInformationParser: router.defaultRouteParser(),
+        routerDelegate: router.delegate(
+          navigatorObservers: () => [TabNavigationObserver(ref: ref)],
+        ),
       ),
     );
   }
 }
+
+// ignore: prefer-single-widget-per-file
+class MainWidget extends StatelessWidget {
+  const MainWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return EasyLocalization(
+      supportedLocales: locales,
+      path: translationsPath,
+      useFallbackTranslations: true,
+      fallbackLocale: locales.first,
+      child: const ImmichApp(),
+    );
+  }
+}

+ 9 - 8
mobile/lib/modules/album/ui/album_viewer_appbar.dart

@@ -43,6 +43,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
   Widget build(BuildContext context, WidgetRef ref) {
     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
+    final isProcessing = useProcessingOverlay();
     final comments = album.shared
         ? ref.watch(
             activityStatisticsStateProvider(
@@ -52,7 +53,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         : 0;
 
     deleteAlbum() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       final bool success;
       if (album.shared) {
@@ -74,7 +75,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         );
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     Future<void> showConfirmationDialog() async {
@@ -122,7 +123,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
     }
 
     void onLeaveAlbumPressed() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       bool isSuccess =
           await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album);
@@ -140,11 +141,11 @@ class AlbumViewerAppbar extends HookConsumerWidget
         );
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     void onRemoveFromAlbumPressed() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       bool isSuccess =
           await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
@@ -167,7 +168,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         );
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     void handleShareAssets(
@@ -198,9 +199,9 @@ class AlbumViewerAppbar extends HookConsumerWidget
     }
 
     void onShareAssetsTo() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
       handleShareAssets(ref, context, selected);
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     buildBottomSheetActions() {

+ 5 - 4
mobile/lib/modules/album/views/album_options_part.dart

@@ -24,6 +24,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
     final owner = album.owner.value;
     final userId = ref.watch(authenticationProvider).userId;
     final activityEnabled = useState(album.activityEnabled);
+    final isProcessing = useProcessingOverlay();
     final isOwner = owner?.id == userId;
 
     void showErrorMessage() {
@@ -37,7 +38,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
     }
 
     void leaveAlbum() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       try {
         final isSuccess =
@@ -54,11 +55,11 @@ class AlbumOptionsPage extends HookConsumerWidget {
         showErrorMessage();
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     void removeUserFromAlbum(User user) async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       try {
         await ref
@@ -71,7 +72,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
       }
 
       context.pop();
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     void handleUserClick(User user) {

+ 14 - 16
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -33,6 +33,7 @@ class AlbumViewerPage extends HookConsumerWidget {
     final userId = ref.watch(authenticationProvider).userId;
     final selection = useState<Set<Asset>>({});
     final multiSelectEnabled = useState(false);
+    final isProcessing = useProcessingOverlay();
 
     useEffect(
       () {
@@ -75,24 +76,21 @@ class AlbumViewerPage extends HookConsumerWidget {
         ),
       );
 
-      if (returnPayload != null) {
+      if (returnPayload != null && returnPayload.selectedAssets.isNotEmpty) {
         // Check if there is new assets add
-        if (returnPayload.selectedAssets.isNotEmpty) {
-          ImmichLoadingOverlayController.appLoader.show();
+        isProcessing.value = true;
 
-          var addAssetsResult =
-              await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
-                    returnPayload.selectedAssets,
-                    albumInfo,
-                  );
+        var addAssetsResult =
+            await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
+                  returnPayload.selectedAssets,
+                  albumInfo,
+                );
 
-          if (addAssetsResult != null &&
-              addAssetsResult.successfullyAdded > 0) {
-            ref.invalidate(albumDetailProvider(albumId));
-          }
-
-          ImmichLoadingOverlayController.appLoader.hide();
+        if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) {
+          ref.invalidate(albumDetailProvider(albumId));
         }
+
+        isProcessing.value = false;
       }
     }
 
@@ -102,7 +100,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       );
 
       if (sharedUserIds != null) {
-        ImmichLoadingOverlayController.appLoader.show();
+        isProcessing.value = true;
 
         var isSuccess = await ref
             .watch(albumServiceProvider)
@@ -112,7 +110,7 @@ class AlbumViewerPage extends HookConsumerWidget {
           ref.invalidate(albumDetailProvider(album.id));
         }
 
-        ImmichLoadingOverlayController.appLoader.hide();
+        isProcessing.value = false;
       }
     }
 

+ 8 - 12
mobile/lib/modules/home/views/home_page.dart

@@ -28,6 +28,7 @@ import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_app_bar.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/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/utils/selection_handlers.dart';
 
 class HomePage extends HookConsumerWidget {
@@ -50,7 +51,7 @@ class HomePage extends HookConsumerWidget {
 
     final tipOneOpacity = useState(0.0);
     final refreshCount = useState(0);
-    final processing = useState(false);
+    final processing = useProcessingOverlay();
 
     useEffect(
       () {
@@ -212,10 +213,10 @@ class HomePage extends HookConsumerWidget {
         processing.value = true;
         selectionEnabledHook.value = false;
         try {
-          ref.read(manualUploadProvider.notifier).uploadAssets(
-                context,
-                selection.value.where((a) => a.storage == AssetState.local),
-              );
+            ref.read(manualUploadProvider.notifier).uploadAssets(
+                  context,
+                  selection.value.where((a) => a.storage == AssetState.local),
+                );
         } finally {
           processing.value = false;
         }
@@ -323,16 +324,12 @@ class HomePage extends HookConsumerWidget {
         } else {
           refreshCount.value++;
           // set counter back to 0 if user does not request refresh again
-          Timer(const Duration(seconds: 4), () {
-            refreshCount.value = 0;
-          });
+          Timer(const Duration(seconds: 4), () => refreshCount.value = 0);
         }
       }
 
       buildLoadingIndicator() {
-        Timer(const Duration(seconds: 2), () {
-          tipOneOpacity.value = 1;
-        });
+        Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
 
         return Center(
           child: Column(
@@ -415,7 +412,6 @@ class HomePage extends HookConsumerWidget {
                 selectionAssetState: selectionAssetState.value,
                 onStack: onStack,
               ),
-            if (processing.value) const Center(child: ImmichLoadingIndicator()),
           ],
         ),
       );

+ 2 - 4
mobile/lib/modules/trash/views/trash_page.dart

@@ -12,8 +12,8 @@ import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/ui/confirm_dialog.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/views/immich_loading_overlay.dart';
 
 class TrashPage extends HookConsumerWidget {
   const TrashPage({super.key});
@@ -25,7 +25,7 @@ class TrashPage extends HookConsumerWidget {
         ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
     final selectionEnabledHook = useState(false);
     final selection = useState(<Asset>{});
-    final processing = useState(false);
+    final processing = useProcessingOverlay();
 
     void selectionListener(
       bool multiselect,
@@ -261,8 +261,6 @@ class TrashPage extends HookConsumerWidget {
                     ),
                   ),
                   if (selectionEnabledHook.value) buildBottomBar(),
-                  if (processing.value)
-                    const Center(child: ImmichLoadingIndicator()),
                 ],
               ),
       ),

+ 1 - 1
mobile/lib/shared/ui/immich_loading_indicator.dart

@@ -21,7 +21,7 @@ class ImmichLoadingIndicator extends StatelessWidget {
       padding: const EdgeInsets.all(15),
       child: const CircularProgressIndicator(
         color: Colors.white,
-        strokeWidth: 2,
+        strokeWidth: 3,
       ),
     );
   }

+ 52 - 29
mobile/lib/shared/views/immich_loading_overlay.dart

@@ -1,41 +1,64 @@
 import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
-class ImmichLoadingOverlay extends StatelessWidget {
-  const ImmichLoadingOverlay({
-    Key? key,
-  }) : super(key: key);
+final _loadingEntry = OverlayEntry(
+  builder: (context) => SizedBox.square(
+    dimension: double.infinity,
+    child: DecoratedBox(
+      decoration:
+          BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
+      child: const Center(child: ImmichLoadingIndicator()),
+    ),
+  ),
+);
+
+ValueNotifier<bool> useProcessingOverlay() {
+  return use(const _LoadingOverlay());
+}
+
+class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
+  const _LoadingOverlay();
 
   @override
-  Widget build(BuildContext context) {
-    return ValueListenableBuilder<bool>(
-      valueListenable:
-          ImmichLoadingOverlayController.appLoader.loaderShowingNotifier,
-      builder: (context, shouldShow, child) {
-        return shouldShow
-            ? const Scaffold(
-                backgroundColor: Colors.black54,
-                body: Center(
-                  child: ImmichLoadingIndicator(),
-                ),
-              )
-            : const SizedBox();
-      },
-    );
-  }
+  _LoadingOverlayState createState() => _LoadingOverlayState();
 }
 
-class ImmichLoadingOverlayController {
-  static final ImmichLoadingOverlayController appLoader =
-      ImmichLoadingOverlayController();
-  ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
-  ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');
+class _LoadingOverlayState
+    extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
+  late final _isProcessing = ValueNotifier(false)..addListener(_listener);
+  OverlayEntry? overlayEntry;
+
+  void _listener() {
+    setState(() {
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        if (_isProcessing.value) {
+          overlayEntry?.remove();
+          overlayEntry = _loadingEntry;
+          Overlay.of(context).insert(_loadingEntry);
+        } else {
+          overlayEntry?.remove();
+          overlayEntry = null;
+        }
+      });
+    });
+  }
 
-  void show() {
-    loaderShowingNotifier.value = true;
+  @override
+  ValueNotifier<bool> build(BuildContext context) {
+    return _isProcessing;
   }
 
-  void hide() {
-    loaderShowingNotifier.value = false;
+  @override
+  void dispose() {
+    _isProcessing.dispose();
+    super.dispose();
   }
+
+  @override
+  Object? get debugValue => _isProcessing.value;
+
+  @override
+  String get debugLabel => 'useProcessingOverlay<>';
 }

+ 18 - 18
mobile/lib/utils/immich_app_theme.dart

@@ -48,8 +48,8 @@ ThemeData immichLightTheme = ThemeData(
     ),
     backgroundColor: Colors.white,
   ),
-  appBarTheme: AppBarTheme(
-    titleTextStyle: const TextStyle(
+  appBarTheme: const AppBarTheme(
+    titleTextStyle: TextStyle(
       fontFamily: 'Overpass',
       color: Colors.indigo,
       fontWeight: FontWeight.bold,
@@ -61,7 +61,7 @@ ThemeData immichLightTheme = ThemeData(
     scrolledUnderElevation: 0,
     centerTitle: true,
   ),
-  bottomNavigationBarTheme: BottomNavigationBarThemeData(
+  bottomNavigationBarTheme: const BottomNavigationBarThemeData(
     type: BottomNavigationBarType.fixed,
     backgroundColor: immichBackgroundColor,
     selectedItemColor: Colors.indigo,
@@ -69,7 +69,7 @@ ThemeData immichLightTheme = ThemeData(
   cardTheme: const CardTheme(
     surfaceTintColor: Colors.transparent,
   ),
-  drawerTheme: DrawerThemeData(
+  drawerTheme: const DrawerThemeData(
     backgroundColor: immichBackgroundColor,
   ),
   textTheme: const TextTheme(
@@ -162,7 +162,7 @@ ThemeData immichDarkTheme = ThemeData(
   hintColor: Colors.grey[600],
   fontFamily: 'Overpass',
   snackBarTheme: SnackBarThemeData(
-    contentTextStyle: TextStyle(
+    contentTextStyle: const TextStyle(
       fontFamily: 'Overpass',
       color: immichDarkThemePrimaryColor,
       fontWeight: FontWeight.bold,
@@ -174,35 +174,35 @@ ThemeData immichDarkTheme = ThemeData(
       foregroundColor: immichDarkThemePrimaryColor,
     ),
   ),
-  appBarTheme: AppBarTheme(
+  appBarTheme: const AppBarTheme(
     titleTextStyle: TextStyle(
       fontFamily: 'Overpass',
       color: immichDarkThemePrimaryColor,
       fontWeight: FontWeight.bold,
       fontSize: 18,
     ),
-    backgroundColor: const Color.fromARGB(255, 32, 33, 35),
+    backgroundColor: Color.fromARGB(255, 32, 33, 35),
     foregroundColor: immichDarkThemePrimaryColor,
     elevation: 0,
     scrolledUnderElevation: 0,
     centerTitle: true,
   ),
-  bottomNavigationBarTheme: BottomNavigationBarThemeData(
+  bottomNavigationBarTheme: const BottomNavigationBarThemeData(
     type: BottomNavigationBarType.fixed,
-    backgroundColor: const Color.fromARGB(255, 35, 36, 37),
+    backgroundColor: Color.fromARGB(255, 35, 36, 37),
     selectedItemColor: immichDarkThemePrimaryColor,
   ),
   drawerTheme: DrawerThemeData(
     backgroundColor: immichDarkBackgroundColor,
     scrimColor: Colors.white.withOpacity(0.1),
   ),
-  textTheme: TextTheme(
-    displayLarge: const TextStyle(
+  textTheme: const TextTheme(
+    displayLarge: TextStyle(
       fontSize: 26,
       fontWeight: FontWeight.bold,
       color: Color.fromARGB(255, 255, 255, 255),
     ),
-    displayMedium: const TextStyle(
+    displayMedium: TextStyle(
       fontSize: 14,
       fontWeight: FontWeight.bold,
       color: Color.fromARGB(255, 255, 255, 255),
@@ -212,15 +212,15 @@ ThemeData immichDarkTheme = ThemeData(
       fontWeight: FontWeight.bold,
       color: immichDarkThemePrimaryColor,
     ),
-    titleSmall: const TextStyle(
+    titleSmall: TextStyle(
       fontSize: 16.0,
       fontWeight: FontWeight.bold,
     ),
-    titleMedium: const TextStyle(
+    titleMedium: TextStyle(
       fontSize: 18.0,
       fontWeight: FontWeight.bold,
     ),
-    titleLarge: const TextStyle(
+    titleLarge: TextStyle(
       fontSize: 26.0,
       fontWeight: FontWeight.bold,
     ),
@@ -258,7 +258,7 @@ ThemeData immichDarkTheme = ThemeData(
   dialogTheme: const DialogTheme(
     surfaceTintColor: Colors.transparent,
   ),
-  inputDecorationTheme: InputDecorationTheme(
+  inputDecorationTheme: const InputDecorationTheme(
     focusedBorder: OutlineInputBorder(
       borderSide: BorderSide(
         color: immichDarkThemePrimaryColor,
@@ -267,12 +267,12 @@ ThemeData immichDarkTheme = ThemeData(
     labelStyle: TextStyle(
       color: immichDarkThemePrimaryColor,
     ),
-    hintStyle: const TextStyle(
+    hintStyle: TextStyle(
       fontSize: 14.0,
       fontWeight: FontWeight.normal,
     ),
   ),
-  textSelectionTheme: TextSelectionThemeData(
+  textSelectionTheme: const TextSelectionThemeData(
     cursorColor: immichDarkThemePrimaryColor,
   ),
 );