diff --git a/i18n/en.json b/i18n/en.json index 5d215e2c36..c83aac618e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -396,6 +396,8 @@ "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen", + "advanced_settings_readonly_mode_title": "Read-only Mode", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", "advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web", @@ -1516,6 +1518,7 @@ "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", "profile_drawer_github": "GitHub", + "profile_drawer_readonly_mode": "Read-only mode enabled. Double-tap the user avatar icon to exit.", "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", "profile_image_of_user": "Profile image of {user}", @@ -1561,6 +1564,8 @@ "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", + "readonly_mode_disabled": "Read-only mode disabled", + "readonly_mode_enabled": "Read-only mode enabled", "reassign": "Reassign", "reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", "reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e4e316b814..6dcd81774a 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -67,6 +67,9 @@ enum StoreKey { loadOriginalVideo._(136), manageLocalMediaAndroid._(137), + // Read-only Mode settings + readonlyModeEnabled._(138), + // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 9064f32066..9bb2895907 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -75,6 +76,7 @@ class _ChangeExperiencePageState extends ConsumerState { await ref.read(backgroundSyncProvider).cancel(); ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); ref.read(websocketProvider.notifier).startListeningToOldEvents(); + ref.read(readonlyModeProvider.notifier).setReadonlyMode(false); await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); await ref.read(driftBackgroundUploadFgService).disableUploadService(); diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 983164831a..41b01ad3a3 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -54,6 +55,7 @@ class _TabShellPageState extends ConsumerState { @override Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final navigationDestinations = [ NavigationDestination( @@ -65,16 +67,19 @@ class _TabShellPageState extends ConsumerState { label: 'search'.tr(), icon: const Icon(Icons.search_rounded), selectedIcon: Icon(Icons.search, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), NavigationDestination( label: 'albums'.tr(), icon: const Icon(Icons.photo_album_outlined), selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), NavigationDestination( label: 'library'.tr(), icon: const Icon(Icons.space_dashboard_outlined), selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), ]; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 6c78cfac3e..5e906b820f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -24,6 +24,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -308,7 +309,7 @@ class _AssetViewerState extends ConsumerState { bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); } - if (distanceToOrigin > openThreshold && !showingBottomSheet) { + if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) { _openBottomSheet(ctx); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 732afee7f9..e581e32df0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_acti import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; @@ -26,6 +27,7 @@ class ViewerBottomBar extends ConsumerWidget { return const SizedBox.shrink(); } + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); @@ -60,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget { duration: Durations.short2, child: AnimatedSwitcher( duration: Durations.short4, - child: isSheetOpen + child: isSheetOpen || isReadonlyModeEnabled ? const SizedBox.shrink() : Theme( data: context.themeData.copyWith( diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 411e279460..570df1afbb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -34,6 +35,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isInLockedView = ref.watch(inLockedViewProvider); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final previousRouteName = ref.watch(previousRouteNameProvider); final showViewInTimelineButton = @@ -94,7 +96,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet + actions: isShowingSheet || isReadonlyModeEnabled ? null : isInLockedView ? lockedViewActions diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 05f96d49de..5eda738e76 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -190,11 +191,12 @@ class _AssetTileWidget extends ConsumerWidget { final lockSelection = _getLockSelectionStatus(ref); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); return RepaintBoundary( child: GestureDetector( onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset), - onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset), + onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset), child: ThumbnailTile( asset, lockSelection: lockSelection, diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart index 8e383a1477..3eff305251 100644 --- a/mobile/lib/presentation/widgets/timeline/header.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -7,9 +7,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class TimelineHeader extends StatelessWidget { +class TimelineHeader extends HookConsumerWidget { final Bucket bucket; final HeaderType header; final double height; @@ -36,13 +37,12 @@ class TimelineHeader extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (bucket is! TimeBucket || header == HeaderType.none) { return const SizedBox.shrink(); } final date = (bucket as TimeBucket).date; - final isMonthHeader = header == HeaderType.month || header == HeaderType.monthAndDay; final isDayHeader = header == HeaderType.day || header == HeaderType.monthAndDay; @@ -98,16 +98,19 @@ class _BulkSelectIconButton extends ConsumerWidget { bucketAssets = []; } + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets)); - return IconButton( - onPressed: () { - ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount); - ref.read(hapticFeedbackProvider.notifier).heavyImpact(); - }, - icon: isAllSelected - ? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor) - : Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary), - ); + return isReadonlyModeEnabled + ? const SizedBox.shrink() + : IconButton( + onPressed: () { + ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount); + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + }, + icon: isAllSelected + ? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor) + : Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary), + ); } } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index c859ae0e80..125f8505a1 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -256,6 +257,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); return PopScope( canPop: !isMultiSelectEnabled, @@ -342,9 +344,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ), }, child: TimelineDragRegion( - onStart: _setDragStartIndex, + onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, onAssetEnter: _handleDragAssetEnter, - onEnd: _stopDrag, + onEnd: !isReadonlyModeEnabled ? _stopDrag : null, onScroll: _dragScroll, onScrollStart: () { // Minimize the bottom sheet when drag selection starts diff --git a/mobile/lib/providers/infrastructure/readonly_mode.provider.dart b/mobile/lib/providers/infrastructure/readonly_mode.provider.dart new file mode 100644 index 0000000000..9e96c3cfc4 --- /dev/null +++ b/mobile/lib/providers/infrastructure/readonly_mode.provider.dart @@ -0,0 +1,36 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; + +class ReadOnlyModeNotifier extends Notifier { + late AppSettingsService _appSettingService; + + @override + bool build() { + _appSettingService = ref.read(appSettingsServiceProvider); + final readonlyMode = _appSettingService.getSetting(AppSettingsEnum.readonlyModeEnabled); + return readonlyMode; + } + + void setMode(bool value) { + _appSettingService.setSetting(AppSettingsEnum.readonlyModeEnabled, value); + state = value; + + if (value) { + ref.read(appRouterProvider).navigate(const MainTimelineRoute()); + } + } + + void setReadonlyMode(bool isEnabled) { + state = isEnabled; + setMode(state); + } + + void toggleReadonlyMode() { + state = !state; + setMode(state); + } +} + +final readonlyModeProvider = NotifierProvider(() => ReadOnlyModeNotifier()); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 8a4b0c6719..d98b14408f 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -49,7 +49,8 @@ enum AppSettingsEnum { betaTimeline(StoreKey.betaTimeline, null, false), enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), - useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false); + useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), + readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index ccfc374fef..b204058859 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart'; @@ -33,6 +34,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { final horizontalPadding = isHorizontal ? 100.0 : 20.0; final user = ref.watch(currentUserProvider); final isLoggingOut = useState(false); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); useEffect(() { ref.read(backupProvider.notifier).updateDiskInfo(); @@ -214,6 +216,25 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); } + buildReadonlyMessage() { + return Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0), + child: ListTile( + dense: true, + visualDensity: VisualDensity.standard, + contentPadding: const EdgeInsets.only(left: 20, right: 20), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + minLeadingWidth: 20, + tileColor: theme.primaryColor.withAlpha(80), + title: Text( + "profile_drawer_readonly_mode", + style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)), + textAlign: TextAlign.center, + ).tr(), + ), + ); + } + return Dismissible( behavior: HitTestBehavior.translucent, direction: DismissDirection.down, @@ -238,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { const AppBarProfileInfoBox(), buildStorageInformation(), const AppBarServerInfo(), + if (isReadonlyModeEnabled) buildReadonlyMessage(), buildAppLogButton(), buildSettingButton(), buildSignOutButton(), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index b1f5b192dd..a9c7a467c2 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,9 +1,12 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -17,6 +20,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authProvider); final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); buildUserProfileImage() { @@ -55,6 +59,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget { } } + void toggleReadonlyMode() { + // read only mode is only supported int he beta experience + // TODO: remove this check when the beta UI goes stable + if (!Store.isBetaTimelineEnabled) return; + + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); + + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0), child: Container( @@ -67,23 +90,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget { minLeadingWidth: 50, leading: GestureDetector( onTap: pickUserProfileImage, + onDoubleTap: toggleReadonlyMode, child: Stack( clipBehavior: Clip.none, children: [ buildUserProfileImage(), - Positioned( - bottom: -5, - right: -8, - child: Material( - color: context.colorScheme.surfaceContainerHighest, - elevation: 3, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), + if (!isReadonlyModeEnabled) + Positioned( + bottom: -5, + right: -8, + child: Material( + color: context.colorScheme.surfaceContainerHighest, + elevation: 3, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), + ), ), ), - ), ], ), ), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 06a97d1ce5..78fa607666 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -42,6 +43,7 @@ class ImmichSliverAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); return SliverAnimatedOpacity( @@ -57,7 +59,7 @@ class ImmichSliverAppBar extends ConsumerWidget { centerTitle: false, title: title ?? const _ImmichLogoWithText(), actions: [ - if (isCasting) + if (isCasting && !isReadonlyModeEnabled) Padding( padding: const EdgeInsets.only(right: 12), child: IconButton( @@ -70,12 +72,13 @@ class ImmichSliverAppBar extends ConsumerWidget { const _SyncStatusIndicator(), if (actions != null) ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if (kDebugMode || kProfileMode) + if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) IconButton( icon: const Icon(Icons.science_rounded), onPressed: () => context.pushRoute(const FeatInDevRoute()), ), - if (showUploadButton) const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), + if (showUploadButton && !isReadonlyModeEnabled) + const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()), ], ), @@ -137,8 +140,24 @@ class _ProfileIndicator extends ConsumerWidget { final user = ref.watch(currentUserProvider); const widgetSize = 30.0; + void toggleReadonlyMode() { + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); + + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } + return InkWell( onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()), + onDoubleTap: () => toggleReadonlyMode(), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( label: Container( diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 3f196b840b..cd2fa93b85 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; @@ -31,6 +34,7 @@ class AdvancedSettings extends HookConsumerWidget { final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); + final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final logLevel = Level.LEVELS[levelId.value].name; @@ -102,6 +106,26 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_enable_alternate_media_filter_title".tr(), subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(), ), + // TODO: Remove this check when beta timeline goes stable + if (Store.isBetaTimelineEnabled) + SettingsSwitchListTile( + valueNotifier: readonlyModeEnabled, + title: "advanced_settings_readonly_mode_title".tr(), + subtitle: "advanced_settings_readonly_mode_subtitle".tr(), + onChanged: (value) { + readonlyModeEnabled.value = value; + ref.read(readonlyModeProvider.notifier).setReadonlyMode(value); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (value ? "readonly_mode_enabled" : "readonly_mode_disabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + }, + ), ]; return SettingsSubPageScaffold(settings: advancedSettings);