mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: locked view mobile (#18316)
* feat: locked/private view * feat: locked/private view * feat: mobile lock/private view * feat: mobile lock/private view * merge main * pr feedback * pr feedback * bottom sheet sizing * always lock when navigating away
This commit is contained in:
parent
397808dd1a
commit
fe71894308
57 changed files with 1893 additions and 289 deletions
|
|
@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/models/asset_selection_state.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
|
|
@ -37,6 +38,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
final void Function()? onEditTime;
|
||||
final void Function()? onEditLocation;
|
||||
final void Function()? onRemoveFromAlbum;
|
||||
final void Function()? onToggleLocked;
|
||||
|
||||
final bool enabled;
|
||||
final bool unfavorite;
|
||||
|
|
@ -58,6 +60,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
this.onEditTime,
|
||||
this.onEditLocation,
|
||||
this.onRemoveFromAlbum,
|
||||
this.onToggleLocked,
|
||||
this.selectionAssetState = const AssetSelectionState(),
|
||||
this.enabled = true,
|
||||
this.unarchive = false,
|
||||
|
|
@ -77,6 +80,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
ref.watch(albumProvider).where((a) => a.shared).toList();
|
||||
const bottomPadding = 0.20;
|
||||
final scrollController = useDraggableScrollController();
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
void minimize() {
|
||||
scrollController.animateTo(
|
||||
|
|
@ -133,11 +137,12 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
label: "share".tr(),
|
||||
onPressed: enabled ? () => onShare(true) : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.link_rounded,
|
||||
label: "control_bottom_app_bar_share_link".tr(),
|
||||
onPressed: enabled ? () => onShare(false) : null,
|
||||
),
|
||||
if (!isInLockedView)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.link_rounded,
|
||||
label: "share_link".tr(),
|
||||
onPressed: enabled ? () => onShare(false) : null,
|
||||
),
|
||||
if (hasRemote && onArchive != null)
|
||||
ControlBoxButton(
|
||||
iconData:
|
||||
|
|
@ -153,7 +158,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
label: (unfavorite ? "unfavorite" : "favorite").tr(),
|
||||
onPressed: enabled ? onFavorite : null,
|
||||
),
|
||||
if (hasLocal && hasRemote && onDelete != null)
|
||||
if (hasLocal && hasRemote && onDelete != null && !isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
child: ControlBoxButton(
|
||||
|
|
@ -166,7 +171,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
enabled ? () => showForceDeleteDialog(onDelete!) : null,
|
||||
),
|
||||
),
|
||||
if (hasRemote && onDeleteServer != null)
|
||||
if (hasRemote && onDeleteServer != null && !isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 85),
|
||||
child: ControlBoxButton(
|
||||
|
|
@ -189,9 +194,23 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
: null,
|
||||
),
|
||||
),
|
||||
if (hasLocal && onDeleteLocal != null)
|
||||
if (isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 85),
|
||||
constraints: const BoxConstraints(maxWidth: 110),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.delete_forever,
|
||||
label: "delete_dialog_title".tr(),
|
||||
onPressed: enabled
|
||||
? () => showForceDeleteDialog(
|
||||
onDeleteServer!,
|
||||
alertMsg: "delete_dialog_alert_remote",
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (hasLocal && onDeleteLocal != null && !isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 95),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.no_cell_outlined,
|
||||
label: "control_bottom_app_bar_delete_from_local".tr(),
|
||||
|
|
@ -231,6 +250,19 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
onPressed: enabled ? onEditLocation : null,
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 100),
|
||||
child: ControlBoxButton(
|
||||
iconData: isInLockedView
|
||||
? Icons.lock_open_rounded
|
||||
: Icons.lock_outline_rounded,
|
||||
label: isInLockedView
|
||||
? "remove_from_locked_folder".tr()
|
||||
: "move_to_locked_folder".tr(),
|
||||
onPressed: enabled ? onToggleLocked : null,
|
||||
),
|
||||
),
|
||||
if (!selectionAssetState.hasLocal &&
|
||||
selectionAssetState.selectedCount > 1 &&
|
||||
onStack != null)
|
||||
|
|
@ -269,20 +301,40 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
];
|
||||
}
|
||||
|
||||
getInitialSize() {
|
||||
if (isInLockedView) {
|
||||
return 0.20;
|
||||
}
|
||||
if (hasRemote) {
|
||||
return 0.35;
|
||||
}
|
||||
return bottomPadding;
|
||||
}
|
||||
|
||||
getMaxChildSize() {
|
||||
if (isInLockedView) {
|
||||
return 0.20;
|
||||
}
|
||||
if (hasRemote) {
|
||||
return 0.65;
|
||||
}
|
||||
return bottomPadding;
|
||||
}
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
controller: scrollController,
|
||||
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
||||
initialChildSize: getInitialSize(),
|
||||
minChildSize: bottomPadding,
|
||||
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
||||
maxChildSize: getMaxChildSize(),
|
||||
snap: true,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
return Card(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 18.0,
|
||||
color: context.colorScheme.surfaceContainerHigh,
|
||||
surfaceTintColor: context.colorScheme.surfaceContainerHigh,
|
||||
elevation: 6.0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
|
|
@ -300,27 +352,27 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
|||
const CustomDraggingHandle(),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
height: 120,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: renderActionButtons(),
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
if (hasRemote && !isInLockedView) ...[
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
thickness: 1,
|
||||
),
|
||||
if (hasRemote)
|
||||
_AddToAlbumTitleRow(
|
||||
onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
if (hasRemote && !isInLockedView)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: AddToAlbumSliverList(
|
||||
|
|
@ -352,12 +404,9 @@ class _AddToAlbumTitleRow extends StatelessWidget {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
"add_to_album",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: context.textTheme.titleSmall,
|
||||
).tr(),
|
||||
TextButton.icon(
|
||||
onPressed: onCreateNewAlbum,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
|
|
@ -15,6 +16,7 @@ import 'package:immich_mobile/providers/album/album.provider.dart';
|
|||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
|
|
@ -395,6 +397,32 @@ class MultiselectGrid extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
void onToggleLockedVisibility() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
final remoteAssets = ownedRemoteSelection(
|
||||
localErrorMessage: 'home_page_locked_error_local'.tr(),
|
||||
ownerErrorMessage: 'home_page_locked_error_partner'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final visibility = isInLockedView
|
||||
? AssetVisibilityEnum.timeline
|
||||
: AssetVisibilityEnum.locked;
|
||||
|
||||
await handleSetAssetsVisibility(
|
||||
ref,
|
||||
context,
|
||||
visibility,
|
||||
remoteAssets.toList(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> Function() wrapLongRunningFun<T>(
|
||||
Future<T> Function() fun, {
|
||||
bool showOverlay = true,
|
||||
|
|
@ -460,6 +488,7 @@ class MultiselectGrid extends HookConsumerWidget {
|
|||
onEditLocation: editEnabled ? onEditLocation : null,
|
||||
unfavorite: unfavorite,
|
||||
unarchive: unarchive,
|
||||
onToggleLocked: onToggleLockedVisibility,
|
||||
onRemoveFromAlbum: onRemoveFromAlbum != null
|
||||
? wrapLongRunningFun(
|
||||
() => onRemoveFromAlbum!(selection.value),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
|||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
|
@ -46,6 +47,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return const SizedBox();
|
||||
|
|
@ -277,7 +279,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
tooltip: 'share'.tr(),
|
||||
): (_) => shareAsset(),
|
||||
},
|
||||
if (asset.isImage)
|
||||
if (asset.isImage && !isInLockedView)
|
||||
{
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.tune_outlined),
|
||||
|
|
@ -285,7 +287,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
tooltip: 'edit'.tr(),
|
||||
): (_) => handleEdit(),
|
||||
},
|
||||
if (isOwner)
|
||||
if (isOwner && !isInLockedView)
|
||||
{
|
||||
asset.isArchived
|
||||
? BottomNavigationBarItem(
|
||||
|
|
@ -299,7 +301,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
tooltip: 'archive'.tr(),
|
||||
): (_) => handleArchive(),
|
||||
},
|
||||
if (isOwner && asset.stackCount > 0)
|
||||
if (isOwner && asset.stackCount > 0 && !isInLockedView)
|
||||
{
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.burst_mode_outlined),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
|||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
|
|
@ -39,6 +40,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
|
|
@ -178,15 +180,22 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
shape: const Border(),
|
||||
actions: [
|
||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||
if (isOwner && !isInHomePage && !(isInTrash ?? false))
|
||||
if (isOwner &&
|
||||
!isInHomePage &&
|
||||
!(isInTrash ?? false) &&
|
||||
!isInLockedView)
|
||||
buildLocateButton(),
|
||||
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||
if (asset.isRemote &&
|
||||
(isOwner || isPartner) &&
|
||||
!asset.isTrashed &&
|
||||
!isInLockedView)
|
||||
buildAddToAlbumButton(),
|
||||
if (asset.isTrashed) buildRestoreButton(),
|
||||
if (album != null && album.shared) buildActivitiesButton(),
|
||||
if (album != null && album.shared && !isInLockedView)
|
||||
buildActivitiesButton(),
|
||||
buildMoreInfoButton(),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ class ControlBoxButton extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MaterialButton(
|
||||
padding: const EdgeInsets.all(10),
|
||||
shape: const CircleBorder(),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
minWidth: 75.0,
|
||||
|
|
@ -47,8 +49,8 @@ class ControlBoxButton extends StatelessWidget {
|
|||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12.0),
|
||||
maxLines: 2,
|
||||
style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
|
||||
maxLines: 3,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class ImmichToast {
|
|||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: context.colorScheme.outline.withValues(alpha: .5),
|
||||
|
|
@ -59,14 +59,23 @@ class ImmichToast {
|
|||
msg,
|
||||
style: TextStyle(
|
||||
color: getColor(toastType, context),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
positionedToastBuilder: (context, child, gravity) {
|
||||
return Positioned(
|
||||
top: gravity == ToastGravity.TOP ? 150 : null,
|
||||
bottom: gravity == ToastGravity.BOTTOM ? 150 : null,
|
||||
left: MediaQuery.of(context).size.width / 2 - 150,
|
||||
right: MediaQuery.of(context).size.width / 2 - 150,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
gravity: gravity,
|
||||
toastDuration: Duration(seconds: durationInSecond),
|
||||
);
|
||||
|
|
|
|||
124
mobile/lib/widgets/forms/pin_input.dart
Normal file
124
mobile/lib/widgets/forms/pin_input.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:pinput/pinput.dart';
|
||||
|
||||
class PinInput extends StatelessWidget {
|
||||
final Function(String)? onCompleted;
|
||||
final Function(String)? onChanged;
|
||||
final int? length;
|
||||
final bool? obscureText;
|
||||
final bool? autoFocus;
|
||||
final bool? hasError;
|
||||
final String? label;
|
||||
final TextEditingController? controller;
|
||||
|
||||
const PinInput({
|
||||
super.key,
|
||||
this.onCompleted,
|
||||
this.onChanged,
|
||||
this.length,
|
||||
this.obscureText,
|
||||
this.autoFocus,
|
||||
this.hasError,
|
||||
this.label,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
getPinSize() {
|
||||
final minimumPadding = 18.0;
|
||||
final gapWidth = 3.0;
|
||||
final screenWidth = context.width;
|
||||
final pinWidth =
|
||||
(screenWidth - (minimumPadding * 2) - (gapWidth * 5)) / (length ?? 6);
|
||||
|
||||
if (pinWidth > 60) {
|
||||
return const Size(60, 64);
|
||||
}
|
||||
|
||||
final pinHeight = pinWidth / (60 / 64);
|
||||
return Size(pinWidth, pinHeight);
|
||||
}
|
||||
|
||||
final defaultPinTheme = PinTheme(
|
||||
width: getPinSize().width,
|
||||
height: getPinSize().height,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 24,
|
||||
color: context.colorScheme.onSurface,
|
||||
fontFamily: 'Overpass Mono',
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(19)),
|
||||
border: Border.all(color: context.colorScheme.surfaceBright),
|
||||
color: context.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label != null) ...[
|
||||
Text(
|
||||
label!,
|
||||
style: context.textTheme.displayLarge
|
||||
?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Pinput(
|
||||
controller: controller,
|
||||
forceErrorState: hasError ?? false,
|
||||
autofocus: autoFocus ?? false,
|
||||
obscureText: obscureText ?? false,
|
||||
obscuringWidget: Icon(
|
||||
Icons.vpn_key_rounded,
|
||||
color: context.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
separatorBuilder: (index) => const SizedBox(
|
||||
height: 64,
|
||||
width: 3,
|
||||
),
|
||||
cursor: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 9),
|
||||
width: 18,
|
||||
height: 2,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
defaultPinTheme: defaultPinTheme,
|
||||
focusedPinTheme: defaultPinTheme.copyWith(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(19)),
|
||||
border: Border.all(
|
||||
color: context.primaryColor.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
),
|
||||
color: context.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
errorPinTheme: defaultPinTheme.copyWith(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.error.withAlpha(15),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(19)),
|
||||
border: Border.all(
|
||||
color: context.colorScheme.error.withAlpha(100),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
pinputAutovalidateMode: PinputAutovalidateMode.onSubmit,
|
||||
length: length ?? 6,
|
||||
onChanged: onChanged,
|
||||
onCompleted: onCompleted,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
128
mobile/lib/widgets/forms/pin_registration_form.dart
Normal file
128
mobile/lib/widgets/forms/pin_registration_form.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_input.dart';
|
||||
|
||||
class PinRegistrationForm extends HookConsumerWidget {
|
||||
final Function() onDone;
|
||||
|
||||
const PinRegistrationForm({
|
||||
super.key,
|
||||
required this.onDone,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasError = useState(false);
|
||||
final newPinCodeController = useTextEditingController();
|
||||
final confirmPinCodeController = useTextEditingController();
|
||||
|
||||
bool validatePinCode() {
|
||||
if (confirmPinCodeController.text.length != 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newPinCodeController.text != confirmPinCodeController.text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createNewPinCode() async {
|
||||
final isValid = validatePinCode();
|
||||
if (!isValid) {
|
||||
hasError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(authProvider.notifier).setupPinCode(
|
||||
newPinCodeController.text,
|
||||
);
|
||||
|
||||
onDone();
|
||||
} catch (error) {
|
||||
hasError.value = true;
|
||||
context.showSnackBar(
|
||||
SnackBar(content: Text(error.toString())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Form(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pin_outlined,
|
||||
size: 64,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: context.width * 0.7,
|
||||
child: Text(
|
||||
'setup_pin_code'.tr(),
|
||||
style: context.textTheme.labelLarge!.copyWith(
|
||||
fontSize: 24,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: context.width * 0.8,
|
||||
child: Text(
|
||||
'new_pin_code_subtitle'.tr(),
|
||||
style: context.textTheme.bodyLarge!.copyWith(
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
PinInput(
|
||||
controller: newPinCodeController,
|
||||
label: 'new_pin_code'.tr(),
|
||||
length: 6,
|
||||
autoFocus: true,
|
||||
hasError: hasError.value,
|
||||
onChanged: (input) {
|
||||
if (input.length < 6) {
|
||||
hasError.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
PinInput(
|
||||
controller: confirmPinCodeController,
|
||||
label: 'confirm_new_pin_code'.tr(),
|
||||
length: 6,
|
||||
hasError: hasError.value,
|
||||
onChanged: (input) {
|
||||
if (input.length < 6) {
|
||||
hasError.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: createNewPinCode,
|
||||
child: Text('create'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
mobile/lib/widgets/forms/pin_verification_form.dart
Normal file
94
mobile/lib/widgets/forms/pin_verification_form.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_input.dart';
|
||||
|
||||
class PinVerificationForm extends HookConsumerWidget {
|
||||
final Function(String) onSuccess;
|
||||
final VoidCallback? onError;
|
||||
final bool? autoFocus;
|
||||
final String? description;
|
||||
final IconData? icon;
|
||||
final IconData? successIcon;
|
||||
|
||||
const PinVerificationForm({
|
||||
super.key,
|
||||
required this.onSuccess,
|
||||
this.onError,
|
||||
this.autoFocus,
|
||||
this.description,
|
||||
this.icon,
|
||||
this.successIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasError = useState(false);
|
||||
final isVerified = useState(false);
|
||||
|
||||
verifyPin(String pinCode) async {
|
||||
final isUnlocked =
|
||||
await ref.read(authProvider.notifier).unlockPinCode(pinCode);
|
||||
|
||||
if (isUnlocked) {
|
||||
isVerified.value = true;
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
onSuccess(pinCode);
|
||||
} else {
|
||||
hasError.value = true;
|
||||
onError?.call();
|
||||
}
|
||||
}
|
||||
|
||||
return Form(
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isVerified.value
|
||||
? Icon(
|
||||
successIcon ?? Icons.lock_open_rounded,
|
||||
size: 64,
|
||||
color: Colors.green[300],
|
||||
)
|
||||
: Icon(
|
||||
icon ?? Icons.lock_outline_rounded,
|
||||
size: 64,
|
||||
color: hasError.value
|
||||
? context.colorScheme.error
|
||||
: context.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
SizedBox(
|
||||
width: context.width * 0.7,
|
||||
child: Text(
|
||||
description ?? 'enter_your_pin_code_subtitle'.tr(),
|
||||
style: context.textTheme.labelLarge!.copyWith(
|
||||
fontSize: 18,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
PinInput(
|
||||
obscureText: true,
|
||||
autoFocus: autoFocus,
|
||||
hasError: hasError.value,
|
||||
length: 6,
|
||||
onChanged: (pinCode) {
|
||||
if (pinCode.length < 6) {
|
||||
hasError.value = false;
|
||||
}
|
||||
},
|
||||
onCompleted: verifyPin,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue