mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
chore: finish drift locked page (#20013)
* feat: overlay mechanism * handle merged asset local id extraction * locked view asset viewer actions * pr feedback
This commit is contained in:
parent
dcfe8d5ade
commit
5d244c6fec
@ -1,13 +1,14 @@
|
||||
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:flutter_hooks/flutter_hooks.dart' show useState;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/local_auth.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
@RoutePage()
|
||||
class PinAuthPage extends HookConsumerWidget {
|
||||
@ -19,6 +20,7 @@ class PinAuthPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final localAuthState = ref.watch(localAuthProvider);
|
||||
final showPinRegistrationForm = useState(createPinCode);
|
||||
final isBetaTimeline = Store.isBetaTimelineEnabled;
|
||||
|
||||
Future<void> registerBiometric(String pinCode) async {
|
||||
final isRegistered =
|
||||
@ -39,7 +41,11 @@ class PinAuthPage extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
context.replaceRoute(const LockedRoute());
|
||||
if (isBetaTimeline) {
|
||||
context.replaceRoute(const DriftLockedFolderRoute());
|
||||
} else {
|
||||
context.replaceRoute(const LockedRoute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,8 +99,14 @@ class PinAuthPage extends HookConsumerWidget {
|
||||
Center(
|
||||
child: PinVerificationForm(
|
||||
autoFocus: true,
|
||||
onSuccess: (_) =>
|
||||
context.replaceRoute(const LockedRoute()),
|
||||
onSuccess: (_) {
|
||||
if (isBetaTimeline) {
|
||||
context
|
||||
.replaceRoute(const DriftLockedFolderRoute());
|
||||
} else {
|
||||
context.replaceRoute(const LockedRoute());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
@ -95,9 +95,13 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
edgeOffset: 100,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
ImmichSliverAppBar(
|
||||
snap: false,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
|
@ -4,14 +4,45 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftLockedFolderPage extends StatelessWidget {
|
||||
class DriftLockedFolderPage extends ConsumerStatefulWidget {
|
||||
const DriftLockedFolderPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftLockedFolderPage> createState() =>
|
||||
_DriftLockedFolderPageState();
|
||||
}
|
||||
|
||||
class _DriftLockedFolderPageState extends ConsumerState<DriftLockedFolderPage>
|
||||
with WidgetsBindingObserver {
|
||||
bool _showOverlay = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showOverlay = state != AppLifecycleState.resumed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProviderScope(
|
||||
@ -30,12 +61,18 @@ class DriftLockedFolderPage extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Timeline(
|
||||
appBar: MesmerizingSliverAppBar(
|
||||
title: 'locked_folder'.t(context: context),
|
||||
),
|
||||
bottomSheet: const LockedFolderBottomSheet(),
|
||||
),
|
||||
child: _showOverlay
|
||||
? const SizedBox()
|
||||
: PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) =>
|
||||
didPop ? ref.read(authProvider.notifier).lockPinCode() : null,
|
||||
child: Timeline(
|
||||
appBar: MesmerizingSliverAppBar(
|
||||
title: 'locked_folder'.t(context: context),
|
||||
),
|
||||
bottomSheet: const LockedFolderBottomSheet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
|
||||
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/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
||||
import 'package:platform/platform.dart';
|
||||
@ -638,6 +639,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
});
|
||||
});
|
||||
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
||||
// Issue: https://github.com/flutter/flutter/issues/109037
|
||||
// TODO: Add a custom scrum builder once the fix lands on stable
|
||||
@ -666,13 +669,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
),
|
||||
bottomNavigationBar: showingBottomSheet
|
||||
? const SizedBox.shrink()
|
||||
: const Column(
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AssetStackRow(),
|
||||
ViewerBottomBar(),
|
||||
const AssetStackRow(),
|
||||
if (!isInLockedView) const ViewerBottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
@ -44,6 +45,8 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
serverInfoProvider.select((state) => state.serverFeatures.trash),
|
||||
);
|
||||
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
final actions = <Widget>[
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (asset.hasRemote) ...[
|
||||
@ -63,8 +66,10 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
],
|
||||
];
|
||||
|
||||
final lockedViewActions = <Widget>[];
|
||||
|
||||
return BaseBottomSheet(
|
||||
actions: actions,
|
||||
actions: isInLockedView ? lockedViewActions : actions,
|
||||
slivers: const [_AssetDetailBottomSheet()],
|
||||
controller: controller,
|
||||
initialChildSize: initialChildSize,
|
||||
|
@ -12,6 +12,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/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
|
||||
@ -27,6 +28,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 isShowingSheet = ref
|
||||
.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
@ -62,6 +64,14 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const _KebabMenu(),
|
||||
];
|
||||
|
||||
final lockedViewActions = <Widget>[
|
||||
if (isCasting || (asset.hasRemote && websocketConnected))
|
||||
const CastActionButton(
|
||||
menuItem: true,
|
||||
),
|
||||
const _KebabMenu(),
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
@ -74,7 +84,11 @@ 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 ? null : actions,
|
||||
actions: isShowingSheet
|
||||
? null
|
||||
: isInLockedView
|
||||
? lockedViewActions
|
||||
: actions,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -47,7 +47,18 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
|
||||
List<String> _getLocalIdsForSource(ActionSource source) {
|
||||
return _getIdsForSource<LocalAsset>(source).toIds().toList(growable: false);
|
||||
final Set<BaseAsset> assets = _getAssets(source);
|
||||
final List<String> localIds = [];
|
||||
|
||||
for (final asset in assets) {
|
||||
if (asset is LocalAsset) {
|
||||
localIds.add(asset.id);
|
||||
} else if (asset is RemoteAsset && asset.localId != null) {
|
||||
localIds.add(asset.localId!);
|
||||
}
|
||||
}
|
||||
|
||||
return localIds;
|
||||
}
|
||||
|
||||
List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
|
||||
@ -162,8 +173,9 @@ class ActionNotifier extends Notifier<void> {
|
||||
|
||||
Future<ActionResult> moveToLockFolder(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source);
|
||||
try {
|
||||
await _service.moveToLockFolder(ids);
|
||||
await _service.moveToLockFolder(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to move assets to lock folder', error, stack);
|
||||
@ -329,7 +341,3 @@ extension on Iterable<RemoteAsset> {
|
||||
return whereType<RemoteAsset>().where((a) => a.ownerId == ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<LocalAsset> {
|
||||
Iterable<String> toIds() => map((e) => e.id);
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ class AppNavigationObserver extends AutoRouterObserver {
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
_handleLockedViewState(route, previousRoute);
|
||||
|
||||
_handleDriftLockedFolderState(route, previousRoute);
|
||||
Future(
|
||||
() => ref.read(currentRouteNameProvider.notifier).state =
|
||||
route.settings.name,
|
||||
@ -54,4 +54,27 @@ class AppNavigationObserver extends AutoRouterObserver {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final isFromLockedViewToDetailView =
|
||||
route.settings.name == AssetViewerRoute.name &&
|
||||
previousRoute?.settings.name == DriftLockedFolderRoute.name;
|
||||
|
||||
final isFromDetailViewToInfoPanelView = route.settings.name == null &&
|
||||
previousRoute?.settings.name == AssetViewerRoute.name &&
|
||||
isInLockedView;
|
||||
|
||||
if (route.settings.name == DriftLockedFolderRoute.name ||
|
||||
isFromLockedViewToDetailView ||
|
||||
isFromDetailViewToInfoPanelView) {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = true,
|
||||
);
|
||||
} else {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -427,7 +427,7 @@ class AppRouter extends RootStackRouter {
|
||||
),
|
||||
AutoRoute(
|
||||
page: DriftLockedFolderRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
guards: [_authGuard, _lockedGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: DriftVideoRoute.page,
|
||||
|
@ -83,7 +83,10 @@ class ActionService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> moveToLockFolder(List<String> remoteIds) async {
|
||||
Future<void> moveToLockFolder(
|
||||
List<String> remoteIds,
|
||||
List<String> localIds,
|
||||
) async {
|
||||
await _assetApiRepository.updateVisibility(
|
||||
remoteIds,
|
||||
AssetVisibilityEnum.locked,
|
||||
@ -92,6 +95,15 @@ class ActionService {
|
||||
remoteIds,
|
||||
AssetVisibility.locked,
|
||||
);
|
||||
|
||||
// Ask user if they want to delete local copies
|
||||
if (localIds.isNotEmpty) {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
|
||||
if (deletedIds.isNotEmpty) {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
|
@ -22,7 +22,6 @@ class MesmerizingSliverAppBar extends ConsumerStatefulWidget {
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
ConsumerState<MesmerizingSliverAppBar> createState() =>
|
||||
_MesmerizingSliverAppBarState();
|
||||
|
Loading…
x
Reference in New Issue
Block a user