mirror of https://github.com/immich-app/immich.git
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 awaypull/18393/head
parent
397808dd1a
commit
fe71894308
@ -1,14 +1,14 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import androidx.annotation.NonNull
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
// No need to set up method channel here as it's now handled in the plugin
|
||||
}
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
// No need to set up method channel here as it's now handled in the plugin
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode
|
||||
setting is off -->
|
||||
<style name="LaunchTheme" parent="Theme.AppCompat.DayNight">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
Flutter draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -1,165 +1,167 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.backgroundFetch</string>
|
||||
<string>app.alextran.immich.backgroundProcessing</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.132.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>205</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.alextran.immich.backgroundFetch</string>
|
||||
<string>app.alextran.immich.backgroundProcessing</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true />
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>ShareHandler</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.file-url</string>
|
||||
<string>public.image</string>
|
||||
<string>public.text</string>
|
||||
<string>public.movie</string>
|
||||
<string>public.url</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ar</string>
|
||||
<string>ca</string>
|
||||
<string>cs</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fi</string>
|
||||
<string>fr</string>
|
||||
<string>he</string>
|
||||
<string>hi</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>lv</string>
|
||||
<string>mn</string>
|
||||
<string>nb</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sk</string>
|
||||
<string>sl</string>
|
||||
<string>sr</string>
|
||||
<string>sv</string>
|
||||
<string>th</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>immich_mobile</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.132.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>205</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false />
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true />
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true />
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We require this permission to access the local WiFi name</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need to manage backup your photos album</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true />
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false />
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true />
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -0,0 +1,6 @@
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
|
||||
abstract interface class IBiometricRepository {
|
||||
Future<BiometricStatus> getStatus();
|
||||
Future<bool> authenticate(String? message);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
abstract interface class ISecureStorageRepository {
|
||||
Future<String?> read(String key);
|
||||
Future<void> write(String key, String value);
|
||||
Future<void> delete(String key);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
class BiometricStatus {
|
||||
final List<BiometricType> availableBiometrics;
|
||||
final bool canAuthenticate;
|
||||
|
||||
const BiometricStatus({
|
||||
required this.availableBiometrics,
|
||||
required this.canAuthenticate,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)';
|
||||
|
||||
BiometricStatus copyWith({
|
||||
List<BiometricType>? availableBiometrics,
|
||||
bool? canAuthenticate,
|
||||
}) {
|
||||
return BiometricStatus(
|
||||
availableBiometrics: availableBiometrics ?? this.availableBiometrics,
|
||||
canAuthenticate: canAuthenticate ?? this.canAuthenticate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BiometricStatus other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.availableBiometrics, availableBiometrics) &&
|
||||
other.canAuthenticate == canAuthenticate;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode;
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
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: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/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LockedPage extends HookConsumerWidget {
|
||||
const LockedPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLifeCycle = useAppLifecycleState();
|
||||
final showOverlay = useState(false);
|
||||
final authProviderNotifier = ref.read(authProvider.notifier);
|
||||
// lock the page when it is destroyed
|
||||
useEffect(
|
||||
() {
|
||||
return () {
|
||||
authProviderNotifier.lockPinCode();
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (context.mounted) {
|
||||
if (appLifeCycle == AppLifecycleState.resumed) {
|
||||
showOverlay.value = false;
|
||||
} else {
|
||||
showOverlay.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[appLifeCycle],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: ref.watch(multiselectProvider) ? null : const LockPageAppBar(),
|
||||
body: showOverlay.value
|
||||
? const SizedBox()
|
||||
: MultiselectGrid(
|
||||
renderListProvider: lockedTimelineProvider,
|
||||
topWidget: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'no_locked_photos_message'.tr(),
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
editEnabled: false,
|
||||
favoriteEnabled: false,
|
||||
unfavorite: false,
|
||||
archiveEnabled: false,
|
||||
stackEnabled: false,
|
||||
unarchive: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LockPageAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const LockPageAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.read(authProvider.notifier).lockPinCode();
|
||||
context.maybePop();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text(
|
||||
'locked_folder',
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
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: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';
|
||||
|
||||
@RoutePage()
|
||||
class PinAuthPage extends HookConsumerWidget {
|
||||
final bool createPinCode;
|
||||
|
||||
const PinAuthPage({super.key, this.createPinCode = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final localAuthState = ref.watch(localAuthProvider);
|
||||
final showPinRegistrationForm = useState(createPinCode);
|
||||
|
||||
Future<void> registerBiometric(String pinCode) async {
|
||||
final isRegistered =
|
||||
await ref.read(localAuthProvider.notifier).registerBiometric(
|
||||
context,
|
||||
pinCode,
|
||||
);
|
||||
|
||||
if (isRegistered) {
|
||||
context.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'biometric_auth_enabled'.tr(),
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: context.colorScheme.primaryContainer,
|
||||
),
|
||||
);
|
||||
|
||||
context.replaceRoute(const LockedRoute());
|
||||
}
|
||||
}
|
||||
|
||||
enableBiometricAuth() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (buildContext) {
|
||||
return SimpleDialog(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PinVerificationForm(
|
||||
description: 'enable_biometric_auth_description'.tr(),
|
||||
onSuccess: (pinCode) {
|
||||
Navigator.pop(buildContext);
|
||||
registerBiometric(pinCode);
|
||||
},
|
||||
autoFocus: true,
|
||||
icon: Icons.fingerprint_rounded,
|
||||
successIcon: Icons.fingerprint_rounded,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('locked_folder'.tr()),
|
||||
),
|
||||
body: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 36.0),
|
||||
child: showPinRegistrationForm.value
|
||||
? Center(
|
||||
child: PinRegistrationForm(
|
||||
onDone: () => showPinRegistrationForm.value = false,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Center(
|
||||
child: PinVerificationForm(
|
||||
autoFocus: true,
|
||||
onSuccess: (_) =>
|
||||
context.replaceRoute(const LockedRoute()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (localAuthState.canAuthenticate) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(
|
||||
Icons.fingerprint,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: enableBiometricAuth,
|
||||
label: Text(
|
||||
'use_biometric'.tr(),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localAuthProvider =
|
||||
StateNotifierProvider<LocalAuthNotifier, BiometricStatus>((ref) {
|
||||
return LocalAuthNotifier(
|
||||
ref.watch(localAuthServiceProvider),
|
||||
ref.watch(secureStorageServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class LocalAuthNotifier extends StateNotifier<BiometricStatus> {
|
||||
final LocalAuthService _localAuthService;
|
||||
final SecureStorageService _secureStorageService;
|
||||
|
||||
final _log = Logger("LocalAuthNotifier");
|
||||
|
||||
LocalAuthNotifier(this._localAuthService, this._secureStorageService)
|
||||
: super(
|
||||
const BiometricStatus(
|
||||
availableBiometrics: [],
|
||||
canAuthenticate: false,
|
||||
),
|
||||
) {
|
||||
_localAuthService.getStatus().then((value) {
|
||||
state = state.copyWith(
|
||||
canAuthenticate: value.canAuthenticate,
|
||||
availableBiometrics: value.availableBiometrics,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> registerBiometric(BuildContext context, String pinCode) async {
|
||||
final isAuthenticated =
|
||||
await authenticate(context, 'Authenticate to enable biometrics');
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _secureStorageService.write(kSecuredPinCode, pinCode);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> authenticate(BuildContext context, String? message) async {
|
||||
String errorMessage = "";
|
||||
|
||||
try {
|
||||
return await _localAuthService.authenticate(message);
|
||||
} on PlatformException catch (error) {
|
||||
switch (error.code) {
|
||||
case "NotEnrolled":
|
||||
_log.warning("User is not enrolled in biometrics");
|
||||
errorMessage = "biometric_no_options".tr();
|
||||
break;
|
||||
case "NotAvailable":
|
||||
_log.warning("Biometric authentication is not available");
|
||||
errorMessage = "biometric_not_available".tr();
|
||||
break;
|
||||
case "LockedOut":
|
||||
_log.warning("User is locked out of biometric authentication");
|
||||
errorMessage = "biometric_locked_out".tr();
|
||||
break;
|
||||
default:
|
||||
_log.warning("Failed to authenticate with unknown reason");
|
||||
errorMessage = 'failed_to_authenticate'.tr();
|
||||
}
|
||||
} catch (error) {
|
||||
_log.warning("Error during authentication: $error");
|
||||
errorMessage = 'failed_to_authenticate'.tr();
|
||||
} finally {
|
||||
if (errorMessage.isNotEmpty) {
|
||||
context.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
errorMessage,
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: context.colorScheme.errorContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||
@ -0,0 +1,10 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final secureStorageProvider =
|
||||
StateNotifierProvider<SecureStorageProvider, void>((ref) {
|
||||
return SecureStorageProvider();
|
||||
});
|
||||
|
||||
class SecureStorageProvider extends StateNotifier<void> {
|
||||
SecureStorageProvider() : super(null);
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/biometric.interface.dart';
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
final biometricRepositoryProvider =
|
||||
Provider((ref) => BiometricRepository(LocalAuthentication()));
|
||||
|
||||
class BiometricRepository implements IBiometricRepository {
|
||||
final LocalAuthentication _localAuth;
|
||||
|
||||
BiometricRepository(this._localAuth);
|
||||
|
||||
@override
|
||||
Future<BiometricStatus> getStatus() async {
|
||||
final bool canAuthenticateWithBiometrics =
|
||||
await _localAuth.canCheckBiometrics;
|
||||
final bool canAuthenticate =
|
||||
canAuthenticateWithBiometrics || await _localAuth.isDeviceSupported();
|
||||
final availableBiometric = await _localAuth.getAvailableBiometrics();
|
||||
|
||||
return BiometricStatus(
|
||||
canAuthenticate: canAuthenticate,
|
||||
availableBiometrics: availableBiometric,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> authenticate(String? message) async {
|
||||
return _localAuth.authenticate(
|
||||
localizedReason: message ?? 'please_auth_to_access'.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/secure_storage.interface.dart';
|
||||
|
||||
final secureStorageRepositoryProvider =
|
||||
Provider((ref) => SecureStorageRepository(const FlutterSecureStorage()));
|
||||
|
||||
class SecureStorageRepository implements ISecureStorageRepository {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
SecureStorageRepository(this._secureStorage);
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) {
|
||||
return _secureStorage.read(key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) {
|
||||
return _secureStorage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) {
|
||||
return _secureStorage.delete(key: key);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AppNavigationObserver extends AutoRouterObserver {
|
||||
/// Riverpod Instance
|
||||
final WidgetRef ref;
|
||||
|
||||
AppNavigationObserver({
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> didChangeTabRoute(
|
||||
TabPageRoute route,
|
||||
TabPageRoute previousRoute,
|
||||
) async {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
_handleLockedViewState(route, previousRoute);
|
||||
}
|
||||
|
||||
_handleLockedViewState(Route route, Route? previousRoute) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final isFromLockedViewToDetailView =
|
||||
route.settings.name == GalleryViewerRoute.name &&
|
||||
previousRoute?.settings.name == LockedRoute.name;
|
||||
|
||||
final isFromDetailViewToInfoPanelView = route.settings.name == null &&
|
||||
previousRoute?.settings.name == GalleryViewerRoute.name &&
|
||||
isInLockedView;
|
||||
|
||||
if (route.settings.name == LockedRoute.name ||
|
||||
isFromLockedViewToDetailView ||
|
||||
isFromDetailViewToInfoPanelView) {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = true,
|
||||
);
|
||||
} else {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:local_auth/error_codes.dart' as auth_error;
|
||||
import 'package:logging/logging.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class LockedGuard extends AutoRouteGuard {
|
||||
final ApiService _apiService;
|
||||
final SecureStorageService _secureStorageService;
|
||||
final LocalAuthService _localAuth;
|
||||
final _log = Logger("AuthGuard");
|
||||
|
||||
LockedGuard(
|
||||
this._apiService,
|
||||
this._secureStorageService,
|
||||
this._localAuth,
|
||||
);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||
final authStatus = await _apiService.authenticationApi.getAuthStatus();
|
||||
|
||||
if (authStatus == null) {
|
||||
resolver.next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Check if a pincode has been created but this user. Show the form to create if not exist
|
||||
if (!authStatus.pinCode) {
|
||||
router.push(PinAuthRoute(createPinCode: true));
|
||||
}
|
||||
|
||||
if (authStatus.isElevated) {
|
||||
resolver.next(true);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Check if the user has the pincode saved in secure storage, meaning
|
||||
/// the user has enabled the biometric authentication
|
||||
final securePinCode = await _secureStorageService.read(kSecuredPinCode);
|
||||
if (securePinCode == null) {
|
||||
router.push(PinAuthRoute());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final bool isAuth = await _localAuth.authenticate();
|
||||
|
||||
if (!isAuth) {
|
||||
resolver.next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiService.authenticationApi.unlockAuthSession(
|
||||
SessionUnlockDto(pinCode: securePinCode),
|
||||
);
|
||||
|
||||
resolver.next(true);
|
||||
} on PlatformException catch (error) {
|
||||
switch (error.code) {
|
||||
case auth_error.notAvailable:
|
||||
_log.severe("notAvailable: $error");
|
||||
break;
|
||||
case auth_error.notEnrolled:
|
||||
_log.severe("not enrolled");
|
||||
break;
|
||||
default:
|
||||
_log.severe("error");
|
||||
break;
|
||||
}
|
||||
|
||||
resolver.next(false);
|
||||
} on ApiException {
|
||||
// PIN code has changed, need to re-enter to access
|
||||
await _secureStorageService.delete(kSecuredPinCode);
|
||||
router.push(PinAuthRoute());
|
||||
} catch (error) {
|
||||
_log.severe("Failed to access locked page", error);
|
||||
resolver.next(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
|
||||
class TabNavigationObserver extends AutoRouterObserver {
|
||||
/// Riverpod Instance
|
||||
final WidgetRef ref;
|
||||
|
||||
TabNavigationObserver({
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> didChangeTabRoute(
|
||||
TabPageRoute route,
|
||||
TabPageRoute previousRoute,
|
||||
) async {
|
||||
if (route.name == 'HomeRoute') {
|
||||
ref.invalidate(memoryFutureProvider);
|
||||
Future(() => ref.read(assetProvider.notifier).getAllAsset());
|
||||
|
||||
// Update user info
|
||||
try {
|
||||
ref.read(userServiceProvider).refreshMyUser();
|
||||
ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
} catch (e) {
|
||||
debugPrint("Error refreshing user info $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/biometric.interface.dart';
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
import 'package:immich_mobile/repositories/biometric.repository.dart';
|
||||
|
||||
final localAuthServiceProvider = Provider(
|
||||
(ref) => LocalAuthService(
|
||||
ref.watch(biometricRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class LocalAuthService {
|
||||
// final _log = Logger("LocalAuthService");
|
||||
|
||||
final IBiometricRepository _biometricRepository;
|
||||
|
||||
LocalAuthService(this._biometricRepository);
|
||||
|
||||
Future<BiometricStatus> getStatus() {
|
||||
return _biometricRepository.getStatus();
|
||||
}
|
||||
|
||||
Future<bool> authenticate([String? message]) async {
|
||||
return _biometricRepository.authenticate(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/secure_storage.interface.dart';
|
||||
import 'package:immich_mobile/repositories/secure_storage.repository.dart';
|
||||
|
||||
final secureStorageServiceProvider = Provider(
|
||||
(ref) => SecureStorageService(
|
||||
ref.watch(secureStorageRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class SecureStorageService {
|
||||
// final _log = Logger("LocalAuthService");
|
||||
|
||||
final ISecureStorageRepository _secureStorageRepository;
|
||||
|
||||
SecureStorageService(this._secureStorageRepository);
|
||||
|
||||
Future<void> write(String key, String value) async {
|
||||
await _secureStorageRepository.write(key, value);
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async {
|
||||
await _secureStorageRepository.delete(key);
|
||||
}
|
||||
|
||||
Future<String?> read(String key) async {
|
||||
return _secureStorageRepository.read(key);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in New Issue