Browse Source

Add settings screen on mobile (#463)

* Refactor profile drawer to sub component

* Added setting page, routing with some options

* Added setting service

* Implement three stage settings

* get app setting for three stage loading
Alex 3 years ago
parent
commit
30f069a5db

+ 2 - 1
mobile/assets/i18n/en-US.json

@@ -75,7 +75,8 @@
   "login_form_save_login": "Stay logged in",
   "monthly_title_text_date_format": "MMMM y",
   "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
-  "profile_drawer_sign_out": "Sign Out",
+  "profile_drawer_sign_out": "Sign out",
+  "profile_drawer_settings": "Settings",
   "search_bar_hint": "Search your photos",
   "search_page_no_objects": "No Objects Info Available",
   "search_page_no_places": "No Places Info Available",

+ 3 - 0
mobile/lib/constants/hive_box.dart

@@ -16,3 +16,6 @@ const String backupInfoKey = "immichBackupAlbumInfoKey"; // Key 1
 // Github Release Info
 const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
 const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
+
+// User Setting Info
+const String userSettingInfoBox = "immichUserSettingInfoBox";

+ 1 - 0
mobile/lib/main.dart

@@ -33,6 +33,7 @@ void main() async {
   await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
   await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
   await Hive.openBox(hiveGithubReleaseInfoBox);
+  await Hive.openBox(userSettingInfoBox);
 
   SystemChrome.setSystemUIOverlayStyle(
     const SystemUiOverlayStyle(

+ 17 - 17
mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart

@@ -56,11 +56,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
   }
 
   void _fireStartLoadingEvent() {
-    if (widget.onLoadingStart != null) widget.onLoadingStart!();
+    widget.onLoadingStart();
   }
 
   void _fireFinishedLoadingEvent() {
-    if (widget.onLoadingCompleted != null) widget.onLoadingCompleted!();
+    widget.onLoadingCompleted();
   }
 
   CachedNetworkImageProvider _authorizedImageProvider(String url) {
@@ -141,26 +141,26 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 }
 
 class RemotePhotoView extends StatefulWidget {
-  const RemotePhotoView(
-      {Key? key,
-      required this.thumbnailUrl,
-      required this.imageUrl,
-      required this.authToken,
-      required this.isZoomedFunction,
-      required this.isZoomedListener,
-      required this.onSwipeDown,
-      required this.onSwipeUp,
-      this.previewUrl,
-      this.onLoadingCompleted,
-      this.onLoadingStart})
-      : super(key: key);
+  const RemotePhotoView({
+    Key? key,
+    required this.thumbnailUrl,
+    required this.imageUrl,
+    required this.authToken,
+    required this.isZoomedFunction,
+    required this.isZoomedListener,
+    required this.onSwipeDown,
+    required this.onSwipeUp,
+    this.previewUrl,
+    required this.onLoadingCompleted,
+    required this.onLoadingStart,
+  }) : super(key: key);
 
   final String thumbnailUrl;
   final String imageUrl;
   final String authToken;
   final String? previewUrl;
-  final Function? onLoadingCompleted;
-  final Function? onLoadingStart;
+  final Function onLoadingCompleted;
+  final Function onLoadingStart;
 
   final void Function() onSwipeDown;
   final void Function() onSwipeUp;

+ 27 - 14
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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/home/services/asset.service.dart';
+import 'package:immich_mobile/shared/services/app_settings.service.dart';
 import 'package:openapi/api.dart';
 
 // ignore: must_be_immutable
@@ -18,8 +19,6 @@ class GalleryViewerPage extends HookConsumerWidget {
   late List<AssetResponseDto> assetList;
   final AssetResponseDto asset;
 
-  static const _threeStageLoading = false;
-
   GalleryViewerPage({
     Key? key,
     required this.assetList,
@@ -27,21 +26,35 @@ class GalleryViewerPage extends HookConsumerWidget {
   }) : super(key: key);
 
   AssetResponseDto? assetDetail;
+
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final Box<dynamic> box = Hive.box(userInfoBox);
+    final appSettingService = ref.watch(appSettingsServiceProvider);
+    final threeStageLoading = useState(false);
+    final loading = useState(false);
+    final isZoomed = useState<bool>(false);
+    ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
 
     int indexOfAsset = assetList.indexOf(asset);
-    final loading = useState(false);
+
+    PageController controller =
+        PageController(initialPage: assetList.indexOf(asset));
+
+    useEffect(
+      () {
+        threeStageLoading.value = appSettingService
+            .getSetting<bool>(AppSettingsEnum.threeStageLoading);
+        return null;
+      },
+      [],
+    );
 
     @override
-    void initState(int index) {
+    initState(int index) {
       indexOfAsset = index;
     }
 
-    PageController controller =
-        PageController(initialPage: assetList.indexOf(asset));
-
     getAssetExif() async {
       assetDetail = await ref
           .watch(assetServiceProvider)
@@ -60,9 +73,6 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
     }
 
-    final isZoomed = useState<bool>(false);
-    ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
-
     //make isZoomed listener call instead
     void isZoomedMethod() {
       if (isZoomedListener.value) {
@@ -84,7 +94,8 @@ class GalleryViewerPage extends HookConsumerWidget {
           ref
               .watch(imageViewerStateProvider.notifier)
               .downloadAsset(assetList[indexOfAsset], context);
-        }, onSharePressed: () {
+        },
+        onSharePressed: () {
           ref
               .watch(imageViewerStateProvider.notifier)
               .shareAsset(assetList[indexOfAsset], context);
@@ -101,17 +112,19 @@ class GalleryViewerPage extends HookConsumerWidget {
           scrollDirection: Axis.horizontal,
           itemBuilder: (context, index) {
             initState(index);
+
             getAssetExif();
+
             if (assetList[index].type == AssetTypeEnum.IMAGE) {
               return ImageViewerPage(
                 authToken: 'Bearer ${box.get(accessTokenKey)}',
                 isZoomedFunction: isZoomedMethod,
                 isZoomedListener: isZoomedListener,
-                onLoadingCompleted: () => loading.value = false,
-                onLoadingStart: () => loading.value = _threeStageLoading,
+                onLoadingCompleted: () => {},
+                onLoadingStart: () => {},
                 asset: assetList[index],
                 heroTag: assetList[index].id,
-                threeStageLoading: _threeStageLoading
+                threeStageLoading: threeStageLoading.value,
               );
             } else {
               return SwipeDetector(

+ 14 - 12
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart

@@ -35,6 +35,7 @@ class ImageViewerPage extends HookConsumerWidget {
   }) : super(key: key);
 
   AssetResponseDto? assetDetail;
+
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final downloadAssetStatus =
@@ -71,18 +72,19 @@ class ImageViewerPage extends HookConsumerWidget {
           child: Hero(
             tag: heroTag,
             child: RemotePhotoView(
-                thumbnailUrl: getThumbnailUrl(asset),
-                imageUrl: getImageUrl(asset),
-                previewUrl: threeStageLoading
-                    ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
-                    : null,
-                authToken: authToken,
-                isZoomedFunction: isZoomedFunction,
-                isZoomedListener: isZoomedListener,
-                onSwipeDown: () => AutoRouter.of(context).pop(),
-                onSwipeUp: () => showInfo(),
-                onLoadingCompleted: onLoadingCompleted,
-                onLoadingStart: onLoadingStart),
+              thumbnailUrl: getThumbnailUrl(asset),
+              imageUrl: getImageUrl(asset),
+              previewUrl: threeStageLoading
+                  ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
+                  : null,
+              authToken: authToken,
+              isZoomedFunction: isZoomedFunction,
+              isZoomedListener: isZoomedListener,
+              onSwipeDown: () => AutoRouter.of(context).pop(),
+              onSwipeUp: () => showInfo(),
+              onLoadingCompleted: onLoadingCompleted,
+              onLoadingStart: onLoadingStart,
+            ),
           ),
         ),
         if (downloadAssetStatus == DownloadAssetStatus.loading)

+ 0 - 303
mobile/lib/modules/home/ui/profile_drawer.dart

@@ -1,303 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:easy_localization/easy_localization.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hive_flutter/hive_flutter.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:image_picker/image_picker.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
-import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
-import 'package:immich_mobile/routing/router.dart';
-import 'package:immich_mobile/shared/providers/asset.provider.dart';
-import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
-import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
-import 'package:immich_mobile/shared/models/server_info_state.model.dart';
-import 'package:immich_mobile/modules/backup/providers/backup.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/ui/immich_loading_indicator.dart';
-import 'package:package_info_plus/package_info_plus.dart';
-import 'dart:math';
-
-class ProfileDrawer extends HookConsumerWidget {
-  const ProfileDrawer({Key? key}) : super(key: key);
-
-  @override
-  Widget build(BuildContext context, WidgetRef ref) {
-    String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
-    AuthenticationState authState = ref.watch(authenticationProvider);
-    ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
-    final uploadProfileImageStatus =
-        ref.watch(uploadProfileImageProvider).status;
-    final appInfo = useState({});
-    var dummmy = Random().nextInt(1024);
-
-    _getPackageInfo() async {
-      PackageInfo packageInfo = await PackageInfo.fromPlatform();
-
-      appInfo.value = {
-        "version": packageInfo.version,
-        "buildNumber": packageInfo.buildNumber,
-      };
-    }
-
-    _buildUserProfileImage() {
-      if (authState.profileImagePath.isEmpty) {
-        return const CircleAvatar(
-          radius: 35,
-          backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
-          backgroundColor: Colors.transparent,
-        );
-      }
-
-      if (uploadProfileImageStatus == UploadProfileStatus.idle) {
-        if (authState.profileImagePath.isNotEmpty) {
-          return CircleAvatar(
-            radius: 35,
-            backgroundImage: NetworkImage(
-              '$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
-            ),
-            backgroundColor: Colors.transparent,
-          );
-        } else {
-          return const CircleAvatar(
-            radius: 35,
-            backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
-            backgroundColor: Colors.transparent,
-          );
-        }
-      }
-
-      if (uploadProfileImageStatus == UploadProfileStatus.success) {
-        return CircleAvatar(
-          radius: 35,
-          backgroundImage: NetworkImage(
-            '$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
-          ),
-          backgroundColor: Colors.transparent,
-        );
-      }
-
-      if (uploadProfileImageStatus == UploadProfileStatus.failure) {
-        return const CircleAvatar(
-          radius: 35,
-          backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
-          backgroundColor: Colors.transparent,
-        );
-      }
-
-      if (uploadProfileImageStatus == UploadProfileStatus.loading) {
-        return const ImmichLoadingIndicator();
-      }
-
-      return const SizedBox();
-    }
-
-    _pickUserProfileImage() async {
-      final XFile? image = await ImagePicker().pickImage(
-        source: ImageSource.gallery,
-        maxHeight: 1024,
-        maxWidth: 1024,
-      );
-
-      if (image != null) {
-        var success =
-            await ref.watch(uploadProfileImageProvider.notifier).upload(image);
-
-        if (success) {
-          ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
-                ref.read(uploadProfileImageProvider).profileImagePath,
-              );
-        }
-      }
-    }
-
-    useEffect(
-      () {
-        _getPackageInfo();
-        _buildUserProfileImage();
-        return null;
-      },
-      [],
-    );
-    return Drawer(
-      child: Column(
-        mainAxisAlignment: MainAxisAlignment.spaceBetween,
-        children: [
-          ListView(
-            shrinkWrap: true,
-            padding: EdgeInsets.zero,
-            children: [
-              DrawerHeader(
-                decoration: const BoxDecoration(
-                  gradient: LinearGradient(
-                    colors: [
-                      Color.fromARGB(255, 216, 219, 238),
-                      Color.fromARGB(255, 226, 230, 231)
-                    ],
-                    begin: Alignment.centerRight,
-                    end: Alignment.centerLeft,
-                  ),
-                ),
-                child: Column(
-                  mainAxisAlignment: MainAxisAlignment.start,
-                  crossAxisAlignment: CrossAxisAlignment.start,
-                  children: [
-                    Stack(
-                      clipBehavior: Clip.none,
-                      children: [
-                        _buildUserProfileImage(),
-                        Positioned(
-                          bottom: 0,
-                          right: -5,
-                          child: GestureDetector(
-                            onTap: _pickUserProfileImage,
-                            child: Material(
-                              color: Colors.grey[50],
-                              elevation: 2,
-                              shape: RoundedRectangleBorder(
-                                borderRadius: BorderRadius.circular(50.0),
-                              ),
-                              child: Padding(
-                                padding: const EdgeInsets.all(5.0),
-                                child: Icon(
-                                  Icons.edit,
-                                  color: Theme.of(context).primaryColor,
-                                  size: 14,
-                                ),
-                              ),
-                            ),
-                          ),
-                        ),
-                      ],
-                    ),
-                    Text(
-                      "${authState.firstName} ${authState.lastName}",
-                      style: TextStyle(
-                        color: Theme.of(context).primaryColor,
-                        fontWeight: FontWeight.bold,
-                        fontSize: 24,
-                      ),
-                    ),
-                    Text(
-                      authState.userEmail,
-                      style: TextStyle(color: Colors.grey[800], fontSize: 12),
-                    )
-                  ],
-                ),
-              ),
-              ListTile(
-                tileColor: Colors.grey[100],
-                leading: const Icon(
-                  Icons.logout_rounded,
-                  color: Colors.black54,
-                ),
-                title: const Text(
-                  "profile_drawer_sign_out",
-                  style: TextStyle(
-                    color: Colors.black54,
-                    fontSize: 14,
-                    fontWeight: FontWeight.bold,
-                  ),
-                ).tr(),
-                onTap: () async {
-                  bool res =
-                      await ref.watch(authenticationProvider.notifier).logout();
-
-                  if (res) {
-                    ref.watch(backupProvider.notifier).cancelBackup();
-                    ref.watch(assetProvider.notifier).clearAllAsset();
-                    ref.watch(websocketProvider.notifier).disconnect();
-                    // AutoRouter.of(context).popUntilRoot();
-                    AutoRouter.of(context).replace(const LoginRoute());
-                  }
-                },
-              )
-            ],
-          ),
-          Padding(
-            padding: const EdgeInsets.all(8.0),
-            child: Card(
-              elevation: 0,
-              color: Colors.grey[100],
-              shape: RoundedRectangleBorder(
-                borderRadius: BorderRadius.circular(5), // if you need this
-                side: const BorderSide(
-                  color: Color.fromARGB(101, 201, 201, 201),
-                  width: 1,
-                ),
-              ),
-              child: Padding(
-                padding:
-                    const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
-                child: Column(
-                  crossAxisAlignment: CrossAxisAlignment.center,
-                  children: [
-                    Padding(
-                      padding: const EdgeInsets.all(8.0),
-                      child: Text(
-                        serverInfoState.isVersionMismatch
-                            ? serverInfoState.versionMismatchErrorMessage
-                            : "profile_drawer_client_server_up_to_date".tr(),
-                        textAlign: TextAlign.center,
-                        style: TextStyle(
-                          fontSize: 11,
-                          color: Theme.of(context).primaryColor,
-                          fontWeight: FontWeight.w600,
-                        ),
-                      ),
-                    ),
-                    const Divider(),
-                    Row(
-                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                      children: [
-                        Text(
-                          "App Version",
-                          style: TextStyle(
-                            fontSize: 11,
-                            color: Colors.grey[500],
-                            fontWeight: FontWeight.bold,
-                          ),
-                        ),
-                        Text(
-                          "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
-                          style: TextStyle(
-                            fontSize: 11,
-                            color: Colors.grey[500],
-                            fontWeight: FontWeight.bold,
-                          ),
-                        ),
-                      ],
-                    ),
-                    const Divider(),
-                    Row(
-                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                      children: [
-                        Text(
-                          "Server Version",
-                          style: TextStyle(
-                            fontSize: 11,
-                            color: Colors.grey[500],
-                            fontWeight: FontWeight.bold,
-                          ),
-                        ),
-                        Text(
-                          "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
-                          style: TextStyle(
-                            fontSize: 11,
-                            color: Colors.grey[500],
-                            fontWeight: FontWeight.bold,
-                          ),
-                        ),
-                      ],
-                    ),
-                  ],
-                ),
-              ),
-            ),
-          )
-        ],
-      ),
-    );
-  }
-}

+ 93 - 0
mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart

@@ -0,0 +1,93 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/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/routing/router.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
+import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
+import 'package:immich_mobile/shared/providers/websocket.provider.dart';
+
+class ProfileDrawer extends HookConsumerWidget {
+  const ProfileDrawer({Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    _buildSignoutButton() {
+      return ListTile(
+        horizontalTitleGap: 0,
+        leading: SizedBox(
+          height: double.infinity,
+          child: Icon(
+            Icons.logout_rounded,
+            color: Colors.grey[700],
+            size: 20,
+          ),
+        ),
+        title: Text(
+          "profile_drawer_sign_out",
+          style: TextStyle(
+            color: Colors.grey[700],
+            fontSize: 12,
+            fontWeight: FontWeight.bold,
+          ),
+        ).tr(),
+        onTap: () async {
+          bool res = await ref.watch(authenticationProvider.notifier).logout();
+
+          if (res) {
+            ref.watch(backupProvider.notifier).cancelBackup();
+            ref.watch(assetProvider.notifier).clearAllAsset();
+            ref.watch(websocketProvider.notifier).disconnect();
+            AutoRouter.of(context).replace(const LoginRoute());
+          }
+        },
+      );
+    }
+
+    _buildSettingButton() {
+      return ListTile(
+        horizontalTitleGap: 0,
+        leading: SizedBox(
+          height: double.infinity,
+          child: Icon(
+            Icons.settings_rounded,
+            color: Colors.grey[700],
+            size: 20,
+          ),
+        ),
+        title: Text(
+          "profile_drawer_settings",
+          style: TextStyle(
+            color: Colors.grey[700],
+            fontSize: 12,
+            fontWeight: FontWeight.bold,
+          ),
+        ).tr(),
+        onTap: () {
+          AutoRouter.of(context).push(const SettingsRoute());
+        },
+      );
+    }
+
+    return Drawer(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          ListView(
+            shrinkWrap: true,
+            padding: EdgeInsets.zero,
+            children: [
+              const ProfileDrawerHeader(),
+              _buildSettingButton(),
+              _buildSignoutButton(),
+            ],
+          ),
+          const ServerInfoBox()
+        ],
+      ),
+    );
+  }
+}

+ 166 - 0
mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart

@@ -0,0 +1,166 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hive_flutter/hive_flutter.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
+import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class ProfileDrawerHeader extends HookConsumerWidget {
+  const ProfileDrawerHeader({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
+    AuthenticationState authState = ref.watch(authenticationProvider);
+    final uploadProfileImageStatus =
+        ref.watch(uploadProfileImageProvider).status;
+    var dummmy = Random().nextInt(1024);
+
+    _buildUserProfileImage() {
+      if (authState.profileImagePath.isEmpty) {
+        return const CircleAvatar(
+          radius: 35,
+          backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
+          backgroundColor: Colors.transparent,
+        );
+      }
+
+      if (uploadProfileImageStatus == UploadProfileStatus.idle) {
+        if (authState.profileImagePath.isNotEmpty) {
+          return CircleAvatar(
+            radius: 35,
+            backgroundImage: NetworkImage(
+              '$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
+            ),
+            backgroundColor: Colors.transparent,
+          );
+        } else {
+          return const CircleAvatar(
+            radius: 35,
+            backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
+            backgroundColor: Colors.transparent,
+          );
+        }
+      }
+
+      if (uploadProfileImageStatus == UploadProfileStatus.success) {
+        return CircleAvatar(
+          radius: 35,
+          backgroundImage: NetworkImage(
+            '$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
+          ),
+          backgroundColor: Colors.transparent,
+        );
+      }
+
+      if (uploadProfileImageStatus == UploadProfileStatus.failure) {
+        return const CircleAvatar(
+          radius: 35,
+          backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
+          backgroundColor: Colors.transparent,
+        );
+      }
+
+      if (uploadProfileImageStatus == UploadProfileStatus.loading) {
+        return const ImmichLoadingIndicator();
+      }
+
+      return const SizedBox();
+    }
+
+    _pickUserProfileImage() async {
+      final XFile? image = await ImagePicker().pickImage(
+        source: ImageSource.gallery,
+        maxHeight: 1024,
+        maxWidth: 1024,
+      );
+
+      if (image != null) {
+        var success =
+            await ref.watch(uploadProfileImageProvider.notifier).upload(image);
+
+        if (success) {
+          ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
+                ref.read(uploadProfileImageProvider).profileImagePath,
+              );
+        }
+      }
+    }
+
+    useEffect(
+      () {
+        _buildUserProfileImage();
+        return null;
+      },
+      [],
+    );
+
+    return DrawerHeader(
+      decoration: const BoxDecoration(
+        gradient: LinearGradient(
+          colors: [
+            Color.fromARGB(255, 216, 219, 238),
+            Color.fromARGB(255, 242, 242, 242),
+            Colors.white,
+          ],
+          begin: Alignment.centerRight,
+          end: Alignment.centerLeft,
+        ),
+      ),
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.start,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Stack(
+            clipBehavior: Clip.none,
+            children: [
+              _buildUserProfileImage(),
+              Positioned(
+                bottom: 0,
+                right: -5,
+                child: GestureDetector(
+                  onTap: _pickUserProfileImage,
+                  child: Material(
+                    color: Colors.grey[50],
+                    elevation: 2,
+                    shape: RoundedRectangleBorder(
+                      borderRadius: BorderRadius.circular(50.0),
+                    ),
+                    child: Padding(
+                      padding: const EdgeInsets.all(5.0),
+                      child: Icon(
+                        Icons.edit,
+                        color: Theme.of(context).primaryColor,
+                        size: 14,
+                      ),
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          ),
+          Text(
+            "${authState.firstName} ${authState.lastName}",
+            style: TextStyle(
+              color: Theme.of(context).primaryColor,
+              fontWeight: FontWeight.bold,
+              fontSize: 24,
+            ),
+          ),
+          Text(
+            authState.userEmail,
+            style: TextStyle(color: Colors.grey[800], fontSize: 12),
+          )
+        ],
+      ),
+    );
+  }
+}

+ 118 - 0
mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart

@@ -0,0 +1,118 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/server_info_state.model.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:immich_mobile/shared/providers/server_info.provider.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+
+class ServerInfoBox extends HookConsumerWidget {
+  const ServerInfoBox({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
+
+    final appInfo = useState({});
+
+    _getPackageInfo() async {
+      PackageInfo packageInfo = await PackageInfo.fromPlatform();
+
+      appInfo.value = {
+        "version": packageInfo.version,
+        "buildNumber": packageInfo.buildNumber,
+      };
+    }
+
+    useEffect(
+      () {
+        _getPackageInfo();
+        return null;
+      },
+      [],
+    );
+
+    return Padding(
+      padding: const EdgeInsets.all(8.0),
+      child: Card(
+        elevation: 0,
+        color: Colors.grey[100],
+        shape: RoundedRectangleBorder(
+          borderRadius: BorderRadius.circular(5), // if you need this
+          side: const BorderSide(
+            color: Color.fromARGB(101, 201, 201, 201),
+            width: 1,
+          ),
+        ),
+        child: Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.center,
+            children: [
+              Padding(
+                padding: const EdgeInsets.all(8.0),
+                child: Text(
+                  serverInfoState.isVersionMismatch
+                      ? serverInfoState.versionMismatchErrorMessage
+                      : "profile_drawer_client_server_up_to_date".tr(),
+                  textAlign: TextAlign.center,
+                  style: TextStyle(
+                    fontSize: 11,
+                    color: Theme.of(context).primaryColor,
+                    fontWeight: FontWeight.w600,
+                  ),
+                ),
+              ),
+              const Divider(),
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Text(
+                    "App Version",
+                    style: TextStyle(
+                      fontSize: 11,
+                      color: Colors.grey[500],
+                      fontWeight: FontWeight.bold,
+                    ),
+                  ),
+                  Text(
+                    "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
+                    style: TextStyle(
+                      fontSize: 11,
+                      color: Colors.grey[500],
+                      fontWeight: FontWeight.bold,
+                    ),
+                  ),
+                ],
+              ),
+              const Divider(),
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Text(
+                    "Server Version",
+                    style: TextStyle(
+                      fontSize: 11,
+                      color: Colors.grey[500],
+                      fontWeight: FontWeight.bold,
+                    ),
+                  ),
+                  Text(
+                    "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
+                    style: TextStyle(
+                      fontSize: 11,
+                      color: Colors.grey[500],
+                      fontWeight: FontWeight.bold,
+                    ),
+                  ),
+                ],
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 1 - 1
mobile/lib/modules/home/views/home_page.dart

@@ -9,7 +9,7 @@ import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
 import 'package:immich_mobile/modules/home/ui/image_grid.dart';
 import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
 import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
-import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
+import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
 
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';

+ 0 - 0
mobile/lib/modules/settings/models/store_model_here.txt


+ 0 - 0
mobile/lib/modules/settings/providers/store_providers_here.txt


+ 0 - 0
mobile/lib/modules/settings/services/store_services_here.txt


+ 29 - 0
mobile/lib/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart

@@ -0,0 +1,29 @@
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/three_stage_loading.dart';
+
+class ImageViewerQualitySetting extends StatelessWidget {
+  const ImageViewerQualitySetting({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return const ExpansionTile(
+      title: Text(
+        'Image viewer quality',
+        style: TextStyle(
+          fontWeight: FontWeight.bold,
+        ),
+      ),
+      subtitle: Text(
+        'Adjust the quality of the detail image viewer',
+        style: TextStyle(
+          fontSize: 13,
+        ),
+      ),
+      children: [
+        ThreeStageLoading(),
+      ],
+    );
+  }
+}

+ 54 - 0
mobile/lib/modules/settings/ui/image_viewer_quality_setting/three_stage_loading.dart

@@ -0,0 +1,54 @@
+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/app_settings.service.dart';
+
+class ThreeStageLoading extends HookConsumerWidget {
+  const ThreeStageLoading({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final appSettingService = ref.watch(appSettingsServiceProvider);
+
+    final isEnable = useState(false);
+
+    useEffect(
+      () {
+        var isThreeStageLoadingEnable =
+            appSettingService.getSetting(AppSettingsEnum.threeStageLoading);
+
+        isEnable.value = isThreeStageLoadingEnable;
+        return null;
+      },
+      [],
+    );
+
+    void onSwitchChanged(bool switchValue) {
+      appSettingService.setSetting(
+        AppSettingsEnum.threeStageLoading,
+        switchValue,
+      );
+      isEnable.value = switchValue;
+    }
+
+    return SwitchListTile.adaptive(
+      title: const Text(
+        "Enable three stage loading",
+        style: TextStyle(
+          fontSize: 12,
+          fontWeight: FontWeight.bold,
+        ),
+      ),
+      subtitle: const Text(
+        "The three-stage loading delivers the best quality image in exchange for a slower loading speed",
+        style: TextStyle(
+          fontSize: 12,
+        ),
+      ),
+      value: isEnable.value,
+      onChanged: onSwitchChanged,
+    );
+  }
+}

+ 81 - 0
mobile/lib/modules/settings/views/settings_page.dart

@@ -0,0 +1,81 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
+
+class SettingsPage extends HookConsumerWidget {
+  const SettingsPage({Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    return Scaffold(
+      appBar: AppBar(
+        leading: IconButton(
+          iconSize: 20,
+          splashRadius: 24,
+          onPressed: () {
+            Navigator.pop(context);
+          },
+          icon: const Icon(Icons.arrow_back_ios_new_rounded),
+        ),
+        automaticallyImplyLeading: false,
+        centerTitle: false,
+        title: const Text(
+          'Settings',
+          style: TextStyle(
+            fontSize: 16,
+            fontWeight: FontWeight.bold,
+          ),
+        ),
+      ),
+      body: ListView(
+        children: [
+          ...ListTile.divideTiles(
+            context: context,
+            tiles: [
+              const ImageViewerQualitySetting(),
+              const SettingListTile(
+                title: 'Theme',
+                subtitle: 'Choose between light and dark theme',
+              ),
+            ],
+          ).toList(),
+        ],
+      ),
+    );
+  }
+}
+
+class SettingListTile extends StatelessWidget {
+  const SettingListTile({
+    required this.title,
+    required this.subtitle,
+    Key? key,
+  }) : super(key: key);
+
+  final String title;
+  final String subtitle;
+
+  @override
+  Widget build(BuildContext context) {
+    return ListTile(
+      dense: true,
+      title: Text(
+        title,
+        style: const TextStyle(
+          fontWeight: FontWeight.bold,
+        ),
+      ),
+      subtitle: Text(
+        subtitle,
+        style: const TextStyle(
+          fontSize: 12,
+        ),
+      ),
+      trailing: const Icon(
+        Icons.keyboard_arrow_right_rounded,
+        size: 24,
+      ),
+      onTap: () {},
+    );
+  }
+}

+ 2 - 0
mobile/lib/routing/router.dart

@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/album/views/create_album_page.dart';
 import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
 import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
 import 'package:immich_mobile/modules/album/views/sharing_page.dart';
+import 'package:immich_mobile/modules/settings/views/settings_page.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
 import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
@@ -77,6 +78,7 @@ part 'router.gr.dart';
       guards: [AuthGuard],
       transitionsBuilder: TransitionsBuilders.slideBottom,
     ),
+    AutoRoute(page: SettingsPage, guards: [AuthGuard]),
   ],
 )
 class AppRouter extends _$AppRouter {

+ 15 - 1
mobile/lib/routing/router.gr.dart

@@ -137,6 +137,10 @@ class _$AppRouter extends RootStackRouter {
           opaque: true,
           barrierDismissible: false);
     },
+    SettingsRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+          routeData: routeData, child: const SettingsPage());
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
           routeData: routeData, child: const HomePage());
@@ -211,7 +215,9 @@ class _$AppRouter extends RootStackRouter {
         RouteConfig(AlbumPreviewRoute.name,
             path: '/album-preview-page', guards: [authGuard]),
         RouteConfig(FailedBackupStatusRoute.name,
-            path: '/failed-backup-status-page', guards: [authGuard])
+            path: '/failed-backup-status-page', guards: [authGuard]),
+        RouteConfig(SettingsRoute.name,
+            path: '/settings-page', guards: [authGuard])
       ];
 }
 
@@ -546,6 +552,14 @@ class FailedBackupStatusRoute extends PageRouteInfo<void> {
   static const String name = 'FailedBackupStatusRoute';
 }
 
+/// generated route for
+/// [SettingsPage]
+class SettingsRoute extends PageRouteInfo<void> {
+  const SettingsRoute() : super(SettingsRoute.name, path: '/settings-page');
+
+  static const String name = 'SettingsRoute';
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 79 - 0
mobile/lib/shared/services/app_settings.service.dart

@@ -0,0 +1,79 @@
+import 'package:flutter/material.dart';
+import 'package:hive_flutter/hive_flutter.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+
+enum AppSettingsEnum {
+  threeStageLoading, // true, false,
+  themeMode, // "light","dark"
+}
+
+class AppSettingsService {
+  late final Box hiveBox;
+
+  AppSettingsService() {
+    hiveBox = Hive.box(userSettingInfoBox);
+  }
+
+  T getSetting<T>(AppSettingsEnum settingType) {
+    var settingKey = _settingHiveBoxKeyLookup(settingType);
+
+    if (!hiveBox.containsKey(settingKey)) {
+      T defaultSetting = _setDefaultSetting(settingType);
+      return defaultSetting;
+    }
+
+    var result = hiveBox.get(settingKey);
+
+    if (result is T) {
+      return result;
+    } else {
+      debugPrint("Incorrect setting type");
+      throw TypeError();
+    }
+  }
+
+  setSetting<T>(AppSettingsEnum settingType, T value) {
+    var settingKey = _settingHiveBoxKeyLookup(settingType);
+
+    if (hiveBox.containsKey(settingKey)) {
+      var result = hiveBox.get(settingKey);
+
+      if (result is! T) {
+        debugPrint("Incorrect setting type");
+        throw TypeError();
+      }
+
+      hiveBox.put(settingKey, value);
+    } else {
+      hiveBox.put(settingKey, value);
+    }
+  }
+
+  _setDefaultSetting(AppSettingsEnum settingType) {
+    var settingKey = _settingHiveBoxKeyLookup(settingType);
+
+    // Default value of threeStageLoading is false
+    if (settingType == AppSettingsEnum.threeStageLoading) {
+      hiveBox.put(settingKey, false);
+      return false;
+    }
+
+    // Default value of themeMode is "light"
+    if (settingType == AppSettingsEnum.themeMode) {
+      hiveBox.put(settingKey, "light");
+      return "light";
+    }
+  }
+
+  String _settingHiveBoxKeyLookup(AppSettingsEnum settingType) {
+    switch (settingType) {
+      case AppSettingsEnum.threeStageLoading:
+        return 'threeStageLoading';
+      case AppSettingsEnum.themeMode:
+        return 'themeMode';
+    }
+  }
+}
+
+final appSettingsServiceProvider = Provider((ref) => AppSettingsService());

+ 7 - 5
mobile/lib/shared/views/tab_controller_page.dart

@@ -53,21 +53,23 @@ class TabControllerPage extends ConsumerWidget {
                     items: [
                       BottomNavigationBarItem(
                         label: 'tab_controller_nav_photos'.tr(),
-                        icon: const Icon(Icons.photo),
+                        icon: const Icon(Icons.photo_outlined),
+                        activeIcon: const Icon(Icons.photo),
                       ),
                       BottomNavigationBarItem(
                         label: 'tab_controller_nav_search'.tr(),
-                        icon: const Icon(Icons.search),
+                        icon: const Icon(Icons.search_rounded),
+                        activeIcon: const Icon(Icons.search),
                       ),
                       BottomNavigationBarItem(
                         label: 'tab_controller_nav_sharing'.tr(),
                         icon: const Icon(Icons.group_outlined),
+                        activeIcon: const Icon(Icons.group),
                       ),
                       BottomNavigationBarItem(
                         label: 'tab_controller_nav_library'.tr(),
-                        icon: const Icon(
-                          Icons.photo_album_outlined,
-                        ),
+                        icon: const Icon(Icons.photo_album_outlined),
+                        activeIcon: const Icon(Icons.photo_album_rounded),
                       )
                     ],
                   ),