mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 16:56:19 +01:00
Add app lock
This commit is contained in:
parent
fd8251b86c
commit
58171f14c9
28 changed files with 1206 additions and 33 deletions
|
@ -8,14 +8,14 @@ import com.nkming.nc_photos.np_android_core.UriUtil
|
||||||
import com.nkming.nc_photos.np_android_core.logE
|
import com.nkming.nc_photos.np_android_core.logE
|
||||||
import com.nkming.nc_photos.np_android_core.logI
|
import com.nkming.nc_photos.np_android_core.logI
|
||||||
import com.nkming.nc_photos.np_platform_image_processor.NpPlatformImageProcessorPlugin
|
import com.nkming.nc_photos.np_platform_image_processor.NpPlatformImageProcessorPlugin
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler {
|
class MainActivity : FlutterFragmentActivity(), MethodChannel.MethodCallHandler {
|
||||||
companion object {
|
companion object {
|
||||||
private const val METHOD_CHANNEL = "com.nkming.nc_photos/activity"
|
private const val METHOD_CHANNEL = "com.nkming.nc_photos/activity"
|
||||||
|
|
||||||
|
|
18
app/lib/animation_util.dart
Normal file
18
app/lib/animation_util.dart
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
double tremblingTransform(int count, double t) {
|
||||||
|
final tt = (t * count) % 1;
|
||||||
|
return _tremblingTransformT(tt);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _tremblingTransformT(double t) {
|
||||||
|
if (t <= 0 || t >= 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final x = 4 * t;
|
||||||
|
if (x < 1) {
|
||||||
|
return -x;
|
||||||
|
} else if (x < 3) {
|
||||||
|
return x - 1;
|
||||||
|
} else {
|
||||||
|
return 4 - x;
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ class BlocListenerT<B extends StateStreamable<S>, S, T>
|
||||||
extends SingleChildStatelessWidget {
|
extends SingleChildStatelessWidget {
|
||||||
const BlocListenerT({
|
const BlocListenerT({
|
||||||
super.key,
|
super.key,
|
||||||
|
super.child,
|
||||||
required this.selector,
|
required this.selector,
|
||||||
required this.listener,
|
required this.listener,
|
||||||
});
|
});
|
||||||
|
|
|
@ -239,9 +239,36 @@ class PrefController {
|
||||||
_c.pref.getSecondarySeedColor()?.run(Color.new));
|
_c.pref.getSecondarySeedColor()?.run(Color.new));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@npSubjectAccessor
|
||||||
class SecurePrefController {
|
class SecurePrefController {
|
||||||
SecurePrefController(this._c);
|
SecurePrefController(this._c);
|
||||||
|
|
||||||
|
Future<void> setProtectedPageAuthType(ProtectedPageAuthType? value) =>
|
||||||
|
_setOrRemove<ProtectedPageAuthType>(
|
||||||
|
controller: _protectedPageAuthTypeController,
|
||||||
|
setter: (pref, value) => pref.setProtectedPageAuthType(value.index),
|
||||||
|
remover: (pref) => pref.setProtectedPageAuthType(null),
|
||||||
|
value: value,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> setProtectedPageAuthPin(CiString? value) =>
|
||||||
|
_setOrRemove<CiString>(
|
||||||
|
controller: _protectedPageAuthPinController,
|
||||||
|
setter: (pref, value) =>
|
||||||
|
pref.setProtectedPageAuthPin(value.toCaseInsensitiveString()),
|
||||||
|
remover: (pref) => pref.setProtectedPageAuthPin(null),
|
||||||
|
value: value,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> setProtectedPageAuthPassword(CiString? value) =>
|
||||||
|
_setOrRemove<CiString>(
|
||||||
|
controller: _protectedPageAuthPasswordController,
|
||||||
|
setter: (pref, value) =>
|
||||||
|
pref.setProtectedPageAuthPassword(value.toCaseInsensitiveString()),
|
||||||
|
remover: (pref) => pref.setProtectedPageAuthPassword(null),
|
||||||
|
value: value,
|
||||||
|
);
|
||||||
|
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
Future<void> _set<T>({
|
Future<void> _set<T>({
|
||||||
required BehaviorSubject<T> controller,
|
required BehaviorSubject<T> controller,
|
||||||
|
@ -273,6 +300,17 @@ class SecurePrefController {
|
||||||
);
|
);
|
||||||
|
|
||||||
final DiContainer _c;
|
final DiContainer _c;
|
||||||
|
@npSubjectAccessor
|
||||||
|
late final _protectedPageAuthTypeController = BehaviorSubject.seeded(_c
|
||||||
|
.securePref
|
||||||
|
.getProtectedPageAuthType()
|
||||||
|
?.let((e) => ProtectedPageAuthType.values[e]));
|
||||||
|
@npSubjectAccessor
|
||||||
|
late final _protectedPageAuthPinController =
|
||||||
|
BehaviorSubject.seeded(_c.securePref.getProtectedPageAuthPin()?.toCi());
|
||||||
|
@npSubjectAccessor
|
||||||
|
late final _protectedPageAuthPasswordController = BehaviorSubject.seeded(
|
||||||
|
_c.securePref.getProtectedPageAuthPassword()?.toCi());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doSet<T>({
|
Future<void> _doSet<T>({
|
||||||
|
|
|
@ -142,3 +142,32 @@ extension $PrefControllerNpSubjectAccessor on PrefController {
|
||||||
secondarySeedColor.distinct().skip(1);
|
secondarySeedColor.distinct().skip(1);
|
||||||
Color? get secondarySeedColorValue => _secondarySeedColorController.value;
|
Color? get secondarySeedColorValue => _secondarySeedColorController.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension $SecurePrefControllerNpSubjectAccessor on SecurePrefController {
|
||||||
|
// _protectedPageAuthTypeController
|
||||||
|
ValueStream<ProtectedPageAuthType?> get protectedPageAuthType =>
|
||||||
|
_protectedPageAuthTypeController.stream;
|
||||||
|
Stream<ProtectedPageAuthType?> get protectedPageAuthTypeNew =>
|
||||||
|
protectedPageAuthType.skip(1);
|
||||||
|
Stream<ProtectedPageAuthType?> get protectedPageAuthTypeChange =>
|
||||||
|
protectedPageAuthType.distinct().skip(1);
|
||||||
|
ProtectedPageAuthType? get protectedPageAuthTypeValue =>
|
||||||
|
_protectedPageAuthTypeController.value;
|
||||||
|
// _protectedPageAuthPinController
|
||||||
|
ValueStream<CiString?> get protectedPageAuthPin =>
|
||||||
|
_protectedPageAuthPinController.stream;
|
||||||
|
Stream<CiString?> get protectedPageAuthPinNew => protectedPageAuthPin.skip(1);
|
||||||
|
Stream<CiString?> get protectedPageAuthPinChange =>
|
||||||
|
protectedPageAuthPin.distinct().skip(1);
|
||||||
|
CiString? get protectedPageAuthPinValue =>
|
||||||
|
_protectedPageAuthPinController.value;
|
||||||
|
// _protectedPageAuthPasswordController
|
||||||
|
ValueStream<CiString?> get protectedPageAuthPassword =>
|
||||||
|
_protectedPageAuthPasswordController.stream;
|
||||||
|
Stream<CiString?> get protectedPageAuthPasswordNew =>
|
||||||
|
protectedPageAuthPassword.skip(1);
|
||||||
|
Stream<CiString?> get protectedPageAuthPasswordChange =>
|
||||||
|
protectedPageAuthPassword.distinct().skip(1);
|
||||||
|
CiString? get protectedPageAuthPasswordValue =>
|
||||||
|
_protectedPageAuthPasswordController.value;
|
||||||
|
}
|
||||||
|
|
|
@ -109,6 +109,9 @@ enum PrefKey implements PrefKeyInterface {
|
||||||
isVideoPlayerMute,
|
isVideoPlayerMute,
|
||||||
isVideoPlayerLoop,
|
isVideoPlayerLoop,
|
||||||
secondarySeedColor,
|
secondarySeedColor,
|
||||||
|
protectedPageAuthType,
|
||||||
|
protectedPageAuthPin,
|
||||||
|
protectedPageAuthPassword,
|
||||||
;
|
;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -187,6 +190,12 @@ enum PrefKey implements PrefKeyInterface {
|
||||||
return "isVideoPlayerLoop";
|
return "isVideoPlayerLoop";
|
||||||
case PrefKey.secondarySeedColor:
|
case PrefKey.secondarySeedColor:
|
||||||
return "secondarySeedColor";
|
return "secondarySeedColor";
|
||||||
|
case PrefKey.protectedPageAuthType:
|
||||||
|
return "protectedPageAuthType";
|
||||||
|
case PrefKey.protectedPageAuthPin:
|
||||||
|
return "protectedPageAuthPin";
|
||||||
|
case PrefKey.protectedPageAuthPassword:
|
||||||
|
return "protectedPageAuthPassword";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -290,6 +290,44 @@ extension PrefExtension on Pref {
|
||||||
(key, value) => provider.setInt(key, value));
|
(key, value) => provider.setInt(key, value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? getProtectedPageAuthType() =>
|
||||||
|
provider.getInt(PrefKey.protectedPageAuthType);
|
||||||
|
int getProtectedPageAuthTypeOr(int def) => getProtectedPageAuthType() ?? def;
|
||||||
|
Future<bool> setProtectedPageAuthType(int? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return _remove(PrefKey.protectedPageAuthType);
|
||||||
|
} else {
|
||||||
|
return _set<int>(PrefKey.protectedPageAuthType, value,
|
||||||
|
(key, value) => provider.setInt(key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getProtectedPageAuthPin() =>
|
||||||
|
provider.getString(PrefKey.protectedPageAuthPin);
|
||||||
|
String getProtectedPageAuthPinOr(String def) =>
|
||||||
|
getProtectedPageAuthPin() ?? def;
|
||||||
|
Future<bool> setProtectedPageAuthPin(String? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return _remove(PrefKey.protectedPageAuthPin);
|
||||||
|
} else {
|
||||||
|
return _set<String>(PrefKey.protectedPageAuthPin, value,
|
||||||
|
(key, value) => provider.setString(key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getProtectedPageAuthPassword() =>
|
||||||
|
provider.getString(PrefKey.protectedPageAuthPassword);
|
||||||
|
String getProtectedPageAuthPasswordOr(String def) =>
|
||||||
|
getProtectedPageAuthPassword() ?? def;
|
||||||
|
Future<bool> setProtectedPageAuthPassword(String? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return _remove(PrefKey.protectedPageAuthPassword);
|
||||||
|
} else {
|
||||||
|
return _set<String>(PrefKey.protectedPageAuthPassword, value,
|
||||||
|
(key, value) => provider.setString(key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccountPrefExtension on AccountPref {
|
extension AccountPrefExtension on AccountPref {
|
||||||
|
|
|
@ -1435,6 +1435,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"appLockUnlockHint": "Unlock the app",
|
||||||
|
"@appLockUnlockHint": {
|
||||||
|
"description": "Unlock app via selected means (e.g., password) in case app lock is enabled by user"
|
||||||
|
},
|
||||||
|
"appLockUnlockWrongPassword": "Incorrect password",
|
||||||
|
"@appLockUnlockWrongPassword": {
|
||||||
|
"description": "Unlock app via selected means (e.g., password) in case app lock is enabled by user"
|
||||||
|
},
|
||||||
|
|
||||||
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
||||||
"@errorUnauthenticated": {
|
"@errorUnauthenticated": {
|
||||||
|
|
|
@ -227,6 +227,8 @@
|
||||||
"contributorsTooltip",
|
"contributorsTooltip",
|
||||||
"setAsTooltip",
|
"setAsTooltip",
|
||||||
"deleteAccountConfirmDialogText",
|
"deleteAccountConfirmDialogText",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword",
|
||||||
"errorUnauthenticated",
|
"errorUnauthenticated",
|
||||||
"errorDisconnected",
|
"errorDisconnected",
|
||||||
"errorLocked",
|
"errorLocked",
|
||||||
|
@ -240,7 +242,9 @@
|
||||||
"cs": [
|
"cs": [
|
||||||
"settingsThemePrimaryColor",
|
"settingsThemePrimaryColor",
|
||||||
"settingsThemeSecondaryColor",
|
"settingsThemeSecondaryColor",
|
||||||
"settingsThemePresets"
|
"settingsThemePresets",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"de": [
|
"de": [
|
||||||
|
@ -272,7 +276,9 @@
|
||||||
"searchFilterBubbleTypeImageText",
|
"searchFilterBubbleTypeImageText",
|
||||||
"imageSaveOptionDialogTitle",
|
"imageSaveOptionDialogTitle",
|
||||||
"imageSaveOptionDialogContent",
|
"imageSaveOptionDialogContent",
|
||||||
"loopTooltip"
|
"loopTooltip",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"el": [
|
"el": [
|
||||||
|
@ -387,25 +393,33 @@
|
||||||
"accountSettingsTooltip",
|
"accountSettingsTooltip",
|
||||||
"contributorsTooltip",
|
"contributorsTooltip",
|
||||||
"setAsTooltip",
|
"setAsTooltip",
|
||||||
"deleteAccountConfirmDialogText"
|
"deleteAccountConfirmDialogText",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"es": [
|
"es": [
|
||||||
"settingsThemePrimaryColor",
|
"settingsThemePrimaryColor",
|
||||||
"settingsThemeSecondaryColor",
|
"settingsThemeSecondaryColor",
|
||||||
"settingsThemePresets"
|
"settingsThemePresets",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"fi": [
|
"fi": [
|
||||||
"settingsThemePrimaryColor",
|
"settingsThemePrimaryColor",
|
||||||
"settingsThemeSecondaryColor",
|
"settingsThemeSecondaryColor",
|
||||||
"settingsThemePresets"
|
"settingsThemePresets",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"fr": [
|
"fr": [
|
||||||
"settingsThemePrimaryColor",
|
"settingsThemePrimaryColor",
|
||||||
"settingsThemeSecondaryColor",
|
"settingsThemeSecondaryColor",
|
||||||
"settingsThemePresets"
|
"settingsThemePresets",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"it": [
|
"it": [
|
||||||
|
@ -416,7 +430,9 @@
|
||||||
"settingsThemePresets",
|
"settingsThemePresets",
|
||||||
"unmuteTooltip",
|
"unmuteTooltip",
|
||||||
"slideshowTooltip",
|
"slideshowTooltip",
|
||||||
"enhanceColorPopTitle"
|
"enhanceColorPopTitle",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"nl": [
|
"nl": [
|
||||||
|
@ -764,6 +780,8 @@
|
||||||
"contributorsTooltip",
|
"contributorsTooltip",
|
||||||
"setAsTooltip",
|
"setAsTooltip",
|
||||||
"deleteAccountConfirmDialogText",
|
"deleteAccountConfirmDialogText",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword",
|
||||||
"errorUnauthenticated",
|
"errorUnauthenticated",
|
||||||
"errorDisconnected",
|
"errorDisconnected",
|
||||||
"errorLocked",
|
"errorLocked",
|
||||||
|
@ -781,7 +799,9 @@
|
||||||
"settingsThemePresets",
|
"settingsThemePresets",
|
||||||
"enhanceColorPopTitle",
|
"enhanceColorPopTitle",
|
||||||
"imageEditTransformOrientationClockwise",
|
"imageEditTransformOrientationClockwise",
|
||||||
"imageEditTransformOrientationCounterclockwise"
|
"imageEditTransformOrientationCounterclockwise",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"pt": [
|
"pt": [
|
||||||
|
@ -807,19 +827,25 @@
|
||||||
"accountSettingsTooltip",
|
"accountSettingsTooltip",
|
||||||
"contributorsTooltip",
|
"contributorsTooltip",
|
||||||
"setAsTooltip",
|
"setAsTooltip",
|
||||||
"deleteAccountConfirmDialogText"
|
"deleteAccountConfirmDialogText",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ru": [
|
"ru": [
|
||||||
"settingsThemePrimaryColor",
|
"settingsThemePrimaryColor",
|
||||||
"settingsThemeSecondaryColor",
|
"settingsThemeSecondaryColor",
|
||||||
"settingsThemePresets"
|
"settingsThemePresets",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"tr": [
|
"tr": [
|
||||||
"settingsThemePrimaryColor",
|
"settingsThemePrimaryColor",
|
||||||
"settingsThemeSecondaryColor",
|
"settingsThemeSecondaryColor",
|
||||||
"settingsThemePresets"
|
"settingsThemePresets",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"zh": [
|
"zh": [
|
||||||
|
@ -950,7 +976,9 @@
|
||||||
"accountSettingsTooltip",
|
"accountSettingsTooltip",
|
||||||
"contributorsTooltip",
|
"contributorsTooltip",
|
||||||
"setAsTooltip",
|
"setAsTooltip",
|
||||||
"deleteAccountConfirmDialogText"
|
"deleteAccountConfirmDialogText",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
],
|
],
|
||||||
|
|
||||||
"zh_Hant": [
|
"zh_Hant": [
|
||||||
|
@ -1081,6 +1109,8 @@
|
||||||
"accountSettingsTooltip",
|
"accountSettingsTooltip",
|
||||||
"contributorsTooltip",
|
"contributorsTooltip",
|
||||||
"setAsTooltip",
|
"setAsTooltip",
|
||||||
"deleteAccountConfirmDialogText"
|
"deleteAccountConfirmDialogText",
|
||||||
|
"appLockUnlockHint",
|
||||||
|
"appLockUnlockWrongPassword"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
149
app/lib/protected_page_handler.dart
Normal file
149
app/lib/protected_page_handler.dart
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/controller/pref_controller.dart';
|
||||||
|
import 'package:nc_photos/widget/protected_page_password_auth_dialog.dart';
|
||||||
|
import 'package:nc_photos/widget/protected_page_pin_auth_dialog.dart';
|
||||||
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_string/np_string.dart';
|
||||||
|
|
||||||
|
part 'protected_page_handler.g.dart';
|
||||||
|
|
||||||
|
enum ProtectedPageAuthType {
|
||||||
|
biometric,
|
||||||
|
pin,
|
||||||
|
password,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProtectedPageAuthException implements Exception {
|
||||||
|
const ProtectedPageAuthException([this.message]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => "ProtectedPageAuthException: $message";
|
||||||
|
|
||||||
|
final dynamic message;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProtectedPageBuildContextExtension on NavigatorState {
|
||||||
|
Future<T?> pushReplacementProtected<T extends Object?, U extends Object?>(
|
||||||
|
String routeName, {
|
||||||
|
U? result,
|
||||||
|
Object? arguments,
|
||||||
|
}) async {
|
||||||
|
if (await _auth()) {
|
||||||
|
return pushReplacementNamed(routeName,
|
||||||
|
arguments: arguments, result: result);
|
||||||
|
} else {
|
||||||
|
throw const ProtectedPageAuthException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T?> pushProtected<T extends Object?>(
|
||||||
|
String routeName, {
|
||||||
|
Object? arguments,
|
||||||
|
}) async {
|
||||||
|
if (await _auth()) {
|
||||||
|
return pushNamed(routeName, arguments: arguments);
|
||||||
|
} else {
|
||||||
|
throw const ProtectedPageAuthException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _auth() async {
|
||||||
|
final securePrefController = context.read<SecurePrefController>();
|
||||||
|
switch (securePrefController.protectedPageAuthTypeValue) {
|
||||||
|
case null:
|
||||||
|
// unprotected
|
||||||
|
return true;
|
||||||
|
case ProtectedPageAuthType.biometric:
|
||||||
|
return _authBiometric(securePrefController);
|
||||||
|
case ProtectedPageAuthType.pin:
|
||||||
|
return _authPin(securePrefController);
|
||||||
|
case ProtectedPageAuthType.password:
|
||||||
|
return _authPassword(securePrefController);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _authBiometric(SecurePrefController securePrefController) async {
|
||||||
|
if (await _BiometricAuthHandler().auth()) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
if (securePrefController.protectedPageAuthPasswordValue != null) {
|
||||||
|
return _authPassword(securePrefController);
|
||||||
|
} else {
|
||||||
|
return _authPin(securePrefController);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _authPin(SecurePrefController securePrefController) =>
|
||||||
|
_PinAuthHandler(context, securePrefController.protectedPageAuthPinValue!)
|
||||||
|
.auth();
|
||||||
|
|
||||||
|
Future<bool> _authPassword(SecurePrefController securePrefController) =>
|
||||||
|
_PasswordAuthHandler(
|
||||||
|
context, securePrefController.protectedPageAuthPasswordValue!)
|
||||||
|
.auth();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _AuthHandler {
|
||||||
|
Future<bool> auth();
|
||||||
|
}
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class _BiometricAuthHandler implements _AuthHandler {
|
||||||
|
@override
|
||||||
|
Future<bool> auth() async {
|
||||||
|
try {
|
||||||
|
final localAuth = LocalAuthentication();
|
||||||
|
final available = await localAuth.getAvailableBiometrics();
|
||||||
|
if (available.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await localAuth.authenticate(
|
||||||
|
localizedReason: L10n.global().appLockUnlockHint,
|
||||||
|
options: const AuthenticationOptions(
|
||||||
|
biometricOnly: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.severe("[auth] Exception", e, stackTrace);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PinAuthHandler implements _AuthHandler {
|
||||||
|
const _PinAuthHandler(this.context, this.pin);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> auth() async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ProtectedPagePinAuthDialog(pin: pin),
|
||||||
|
);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BuildContext context;
|
||||||
|
final CiString pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordAuthHandler implements _AuthHandler {
|
||||||
|
const _PasswordAuthHandler(this.context, this.password);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> auth() async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ProtectedPagePasswordAuthDialog(password: password),
|
||||||
|
);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BuildContext context;
|
||||||
|
final CiString password;
|
||||||
|
}
|
14
app/lib/protected_page_handler.g.dart
Normal file
14
app/lib/protected_page_handler.g.dart
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'protected_page_handler.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// NpLogGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$_BiometricAuthHandlerNpLog on _BiometricAuthHandler {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log = Logger("protected_page_handler._BiometricAuthHandler");
|
||||||
|
}
|
100
app/lib/widget/protected_page_password_auth_dialog.dart
Normal file
100
app/lib/widget/protected_page_password_auth_dialog.dart
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import 'package:copy_with/copy_with.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/animation_util.dart';
|
||||||
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/bloc_util.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_common/unique.dart';
|
||||||
|
import 'package:np_string/np_string.dart';
|
||||||
|
import 'package:to_string/to_string.dart';
|
||||||
|
|
||||||
|
part 'protected_page_password_auth_dialog.g.dart';
|
||||||
|
part 'protected_page_password_auth_dialog/bloc.dart';
|
||||||
|
part 'protected_page_password_auth_dialog/state_event.dart';
|
||||||
|
part 'protected_page_password_auth_dialog/view.dart';
|
||||||
|
|
||||||
|
class ProtectedPagePasswordAuthDialog extends StatelessWidget {
|
||||||
|
const ProtectedPagePasswordAuthDialog({
|
||||||
|
super.key,
|
||||||
|
required this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => _Bloc(
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
|
child: _WrappedProtectedPagePasswordAuthDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CiString password;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WrappedProtectedPagePasswordAuthDialog extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MultiBlocListener(
|
||||||
|
listeners: [
|
||||||
|
_BlocListenerT(
|
||||||
|
selector: (state) => state.isAuthorized,
|
||||||
|
listener: (context, isAuthorized) {
|
||||||
|
if (isAuthorized.value == true) {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(L10n.global().appLockUnlockHint),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
obscureText: true,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: L10n.global().passwordInputHint,
|
||||||
|
),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
context.addEvent(_Submit(value));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_BlocSelector(
|
||||||
|
selector: (state) => state.isAuthorized,
|
||||||
|
builder: (context, isAuthorized) {
|
||||||
|
if (isAuthorized.value == false) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8, bottom: 4),
|
||||||
|
child: _ErrorNotice(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
|
||||||
|
// typedef _BlocListener = BlocListener<_Bloc, _State>;
|
||||||
|
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
|
||||||
|
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
|
||||||
|
|
||||||
|
extension on BuildContext {
|
||||||
|
_Bloc get bloc => read<_Bloc>();
|
||||||
|
// _State get state => bloc.state;
|
||||||
|
void addEvent(_Event event) => bloc.add(event);
|
||||||
|
}
|
63
app/lib/widget/protected_page_password_auth_dialog.g.dart
Normal file
63
app/lib/widget/protected_page_password_auth_dialog.g.dart
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'protected_page_password_auth_dialog.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithLintRuleGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
abstract class $_StateCopyWithWorker {
|
||||||
|
_State call({Unique<bool?>? isAuthorized});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
|
||||||
|
_$_StateCopyWithWorkerImpl(this.that);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_State call({dynamic isAuthorized}) {
|
||||||
|
return _State(
|
||||||
|
isAuthorized: isAuthorized as Unique<bool?>? ?? that.isAuthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _State that;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension $_StateCopyWith on _State {
|
||||||
|
$_StateCopyWithWorker get copyWith => _$copyWith;
|
||||||
|
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// NpLogGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$_BlocNpLog on _Bloc {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log = Logger("widget.protected_page_password_auth_dialog._Bloc");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// ToStringGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$_StateToString on _State {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_State {isAuthorized: $isAuthorized}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_SubmitToString on _Submit {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_Submit {value: $value}";
|
||||||
|
}
|
||||||
|
}
|
22
app/lib/widget/protected_page_password_auth_dialog/bloc.dart
Normal file
22
app/lib/widget/protected_page_password_auth_dialog/bloc.dart
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
part of '../protected_page_password_auth_dialog.dart';
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
||||||
|
_Bloc({
|
||||||
|
required this.password,
|
||||||
|
}) : _hasher = sha256,
|
||||||
|
super(_State.init()) {
|
||||||
|
on<_Submit>(_onSubmit);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSubmit(_Submit ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
final hash = _hasher.convert(ev.value.codeUnits);
|
||||||
|
final isAuth = hash.toString().toCi() == password;
|
||||||
|
emit(state.copyWith(isAuthorized: Unique(isAuth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
final CiString password;
|
||||||
|
|
||||||
|
final Hash _hasher;
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
part of '../protected_page_password_auth_dialog.dart';
|
||||||
|
|
||||||
|
@genCopyWith
|
||||||
|
@toString
|
||||||
|
class _State {
|
||||||
|
const _State({
|
||||||
|
required this.isAuthorized,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _State.init() => _State(
|
||||||
|
isAuthorized: Unique(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final Unique<bool?> isAuthorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Event {
|
||||||
|
const _Event();
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _Submit implements _Event {
|
||||||
|
const _Submit(this.value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
44
app/lib/widget/protected_page_password_auth_dialog/view.dart
Normal file
44
app/lib/widget/protected_page_password_auth_dialog/view.dart
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
part of '../protected_page_password_auth_dialog.dart';
|
||||||
|
|
||||||
|
class _ErrorNotice extends StatefulWidget {
|
||||||
|
const _ErrorNotice();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _ErrorNoticeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorNoticeState extends State<_ErrorNotice>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_controller.forward(from: 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _BlocListenerT(
|
||||||
|
selector: (state) => state.isAuthorized,
|
||||||
|
listener: (context, isAuthorized) {
|
||||||
|
if (isAuthorized.value == false) {
|
||||||
|
_controller.forward(from: 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _controller.drive(Animatable<Offset>.fromCallback(
|
||||||
|
(t) => Offset(tremblingTransform(3, t) * .05, 0))),
|
||||||
|
child: Text(
|
||||||
|
L10n.global().appLockUnlockWrongPassword,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _controller = AnimationController(vsync: this)
|
||||||
|
..duration = k.animationDurationLong;
|
||||||
|
}
|
154
app/lib/widget/protected_page_pin_auth_dialog.dart
Normal file
154
app/lib/widget/protected_page_pin_auth_dialog.dart
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:copy_with/copy_with.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/animation_util.dart';
|
||||||
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/bloc_util.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_collection/np_collection.dart';
|
||||||
|
import 'package:np_common/unique.dart';
|
||||||
|
import 'package:np_string/np_string.dart';
|
||||||
|
import 'package:to_string/to_string.dart';
|
||||||
|
|
||||||
|
part 'protected_page_pin_auth_dialog.g.dart';
|
||||||
|
part 'protected_page_pin_auth_dialog/bloc.dart';
|
||||||
|
part 'protected_page_pin_auth_dialog/state_event.dart';
|
||||||
|
part 'protected_page_pin_auth_dialog/view.dart';
|
||||||
|
|
||||||
|
class ProtectedPagePinAuthDialog extends StatelessWidget {
|
||||||
|
const ProtectedPagePinAuthDialog({
|
||||||
|
super.key,
|
||||||
|
required this.pin,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => _Bloc(
|
||||||
|
pin: pin,
|
||||||
|
removeItemBuilder: (_, animation, value) => ScaleTransition(
|
||||||
|
scale: animation.drive(CurveTween(curve: Curves.linear)),
|
||||||
|
child: _ObsecuredDigitDisplay(randomInt: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _WrappedProtectedPagePinAuthDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CiString pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WrappedProtectedPagePinAuthDialog extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MultiBlocListener(
|
||||||
|
listeners: [
|
||||||
|
_BlocListenerT(
|
||||||
|
selector: (state) => state.isAuthorized,
|
||||||
|
listener: (context, isAuthorized) {
|
||||||
|
if (isAuthorized) {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(L10n.global().appLockUnlockHint),
|
||||||
|
scrollable: true,
|
||||||
|
content: SizedBox(
|
||||||
|
width: 280,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment(0, -0.5),
|
||||||
|
child: _ObsecuredInputView(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Table(
|
||||||
|
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
children: [
|
||||||
|
TableRow(
|
||||||
|
children: [1, 2, 3]
|
||||||
|
.map(
|
||||||
|
(e) => _DigitButton(
|
||||||
|
child: Text(e.toString()),
|
||||||
|
onTap: () {
|
||||||
|
context.addEvent(_PushDigit(e));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [4, 5, 6]
|
||||||
|
.map(
|
||||||
|
(e) => _DigitButton(
|
||||||
|
child: Text(e.toString()),
|
||||||
|
onTap: () {
|
||||||
|
context.addEvent(_PushDigit(e));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [7, 8, 9]
|
||||||
|
.map(
|
||||||
|
(e) => _DigitButton(
|
||||||
|
child: Text(e.toString()),
|
||||||
|
onTap: () {
|
||||||
|
context.addEvent(_PushDigit(e));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
_DigitButton(
|
||||||
|
child: const Text("0"),
|
||||||
|
onTap: () {
|
||||||
|
context.addEvent(const _PushDigit(0));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_BlocSelector(
|
||||||
|
selector: (state) => state.obsecuredInput.isEmpty,
|
||||||
|
builder: (context, isEmpty) => _BackspaceButton(
|
||||||
|
onTap: isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
context.addEvent(const _PopDigit());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
|
||||||
|
// typedef _BlocListener = BlocListener<_Bloc, _State>;
|
||||||
|
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
|
||||||
|
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
|
||||||
|
|
||||||
|
extension on BuildContext {
|
||||||
|
_Bloc get bloc => read<_Bloc>();
|
||||||
|
_State get state => bloc.state;
|
||||||
|
void addEvent(_Event event) => bloc.add(event);
|
||||||
|
}
|
81
app/lib/widget/protected_page_pin_auth_dialog.g.dart
Normal file
81
app/lib/widget/protected_page_pin_auth_dialog.g.dart
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'protected_page_pin_auth_dialog.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithLintRuleGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
abstract class $_StateCopyWithWorker {
|
||||||
|
_State call(
|
||||||
|
{String? input,
|
||||||
|
List<int>? obsecuredInput,
|
||||||
|
bool? isAuthorized,
|
||||||
|
Unique<bool?>? isPinError});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
|
||||||
|
_$_StateCopyWithWorkerImpl(this.that);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_State call(
|
||||||
|
{dynamic input,
|
||||||
|
dynamic obsecuredInput,
|
||||||
|
dynamic isAuthorized,
|
||||||
|
dynamic isPinError}) {
|
||||||
|
return _State(
|
||||||
|
input: input as String? ?? that.input,
|
||||||
|
obsecuredInput: obsecuredInput as List<int>? ?? that.obsecuredInput,
|
||||||
|
isAuthorized: isAuthorized as bool? ?? that.isAuthorized,
|
||||||
|
isPinError: isPinError as Unique<bool?>? ?? that.isPinError);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _State that;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension $_StateCopyWith on _State {
|
||||||
|
$_StateCopyWithWorker get copyWith => _$copyWith;
|
||||||
|
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// NpLogGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$_BlocNpLog on _Bloc {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log = Logger("widget.protected_page_pin_auth_dialog._Bloc");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// ToStringGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$_StateToString on _State {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_State {input: $input, obsecuredInput: [length: ${obsecuredInput.length}], isAuthorized: $isAuthorized, isPinError: $isPinError}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_PushDigitToString on _PushDigit {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_PushDigit {digit: $digit}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_PopDigitToString on _PopDigit {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "_PopDigit {}";
|
||||||
|
}
|
||||||
|
}
|
68
app/lib/widget/protected_page_pin_auth_dialog/bloc.dart
Normal file
68
app/lib/widget/protected_page_pin_auth_dialog/bloc.dart
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
part of '../protected_page_pin_auth_dialog.dart';
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
|
||||||
|
_Bloc({
|
||||||
|
required this.pin,
|
||||||
|
required this.removeItemBuilder,
|
||||||
|
}) : _rand = Random(),
|
||||||
|
_hasher = sha256,
|
||||||
|
super(_State.init()) {
|
||||||
|
on<_PushDigit>(_onPushDigit);
|
||||||
|
on<_PopDigit>(_onPopDigit);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPushDigit(_PushDigit ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
if (state.input.length >= 6) {
|
||||||
|
// max length of pin is 6
|
||||||
|
emit(state.copyWith(isPinError: Unique(true)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final index = state.input.length;
|
||||||
|
emit(state.copyWith(
|
||||||
|
input: "${state.input}${ev.digit}",
|
||||||
|
obsecuredInput: state.obsecuredInput.added(_rand.nextInt(65536)),
|
||||||
|
));
|
||||||
|
listKey.currentState?.insertItem(
|
||||||
|
index,
|
||||||
|
duration: k.animationDurationLong,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.input.length >= 4) {
|
||||||
|
// valid pin must contain at least 4 digits
|
||||||
|
final hash = _hasher.convert(state.input.codeUnits);
|
||||||
|
if (hash.toString().toCi() == pin) {
|
||||||
|
emit(state.copyWith(isAuthorized: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPopDigit(_PopDigit ev, Emitter<_State> emit) {
|
||||||
|
_log.info(ev);
|
||||||
|
if (state.input.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final index = state.input.length - 1;
|
||||||
|
final item = state.obsecuredInput.last;
|
||||||
|
emit(state.copyWith(
|
||||||
|
input: state.input.slice(0, -1),
|
||||||
|
obsecuredInput: state.obsecuredInput.slice(0, -1),
|
||||||
|
));
|
||||||
|
listKey.currentState?.removeItem(
|
||||||
|
index,
|
||||||
|
(context, animation) => removeItemBuilder(context, animation, item),
|
||||||
|
duration: k.animationDurationNormal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CiString pin;
|
||||||
|
final Widget Function(
|
||||||
|
BuildContext context, Animation<double> animation, int value)
|
||||||
|
removeItemBuilder;
|
||||||
|
|
||||||
|
final listKey = GlobalKey<AnimatedListState>();
|
||||||
|
|
||||||
|
final Random _rand;
|
||||||
|
final Hash _hasher;
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
part of '../protected_page_pin_auth_dialog.dart';
|
||||||
|
|
||||||
|
@genCopyWith
|
||||||
|
@toString
|
||||||
|
class _State {
|
||||||
|
const _State({
|
||||||
|
required this.input,
|
||||||
|
required this.obsecuredInput,
|
||||||
|
required this.isAuthorized,
|
||||||
|
required this.isPinError,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _State.init() => _State(
|
||||||
|
input: "",
|
||||||
|
obsecuredInput: const [],
|
||||||
|
isAuthorized: false,
|
||||||
|
isPinError: Unique(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final String input;
|
||||||
|
final List<int> obsecuredInput;
|
||||||
|
final bool isAuthorized;
|
||||||
|
final Unique<bool?> isPinError;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Event {
|
||||||
|
const _Event();
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _PushDigit implements _Event {
|
||||||
|
const _PushDigit(this.digit);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final int digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@toString
|
||||||
|
class _PopDigit implements _Event {
|
||||||
|
const _PopDigit();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
}
|
130
app/lib/widget/protected_page_pin_auth_dialog/view.dart
Normal file
130
app/lib/widget/protected_page_pin_auth_dialog/view.dart
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
part of '../protected_page_pin_auth_dialog.dart';
|
||||||
|
|
||||||
|
class _ObsecuredInputView extends StatefulWidget {
|
||||||
|
const _ObsecuredInputView();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _ObsecuredInputViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ObsecuredInputViewState extends State<_ObsecuredInputView>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _BlocListenerT(
|
||||||
|
selector: (state) => state.isPinError,
|
||||||
|
listener: (context, isPinError) {
|
||||||
|
if (isPinError.value == true) {
|
||||||
|
_controller.forward(from: 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _controller.drive(Animatable<Offset>.fromCallback(
|
||||||
|
(t) => Offset(tremblingTransform(3, t) * .05, 0))),
|
||||||
|
child: AnimatedList(
|
||||||
|
key: context.bloc.listKey,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
shrinkWrap: true,
|
||||||
|
initialItemCount: context.state.obsecuredInput.length,
|
||||||
|
itemBuilder: (context, index, animation) => ScaleTransition(
|
||||||
|
scale: animation.drive(CurveTween(curve: Curves.elasticOut)),
|
||||||
|
child: _ObsecuredDigitDisplay(
|
||||||
|
randomInt: context.state.obsecuredInput[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _controller = AnimationController(vsync: this)
|
||||||
|
..duration = k.animationDurationLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ObsecuredDigitDisplay extends StatelessWidget {
|
||||||
|
_ObsecuredDigitDisplay({
|
||||||
|
required int randomInt,
|
||||||
|
}) : text = String.fromCharCode(0x1f600 + (randomInt % 0x30));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DigitButton extends StatelessWidget {
|
||||||
|
const _DigitButton({
|
||||||
|
required this.child,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
height: 56,
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium!,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BackspaceButton extends StatelessWidget {
|
||||||
|
const _BackspaceButton({
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
height: 56,
|
||||||
|
child: Icon(
|
||||||
|
Icons.backspace_outlined,
|
||||||
|
color: onTap == null ? Theme.of(context).disabledColor : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import 'package:nc_photos/entity/pref.dart';
|
||||||
import 'package:nc_photos/k.dart' as k;
|
import 'package:nc_photos/k.dart' as k;
|
||||||
import 'package:nc_photos/mobile/android/activity.dart';
|
import 'package:nc_photos/mobile/android/activity.dart';
|
||||||
import 'package:nc_photos/mobile/android/permission_util.dart';
|
import 'package:nc_photos/mobile/android/permission_util.dart';
|
||||||
|
import 'package:nc_photos/protected_page_handler.dart';
|
||||||
import 'package:nc_photos/use_case/compat/v29.dart';
|
import 'package:nc_photos/use_case/compat/v29.dart';
|
||||||
import 'package:nc_photos/use_case/compat/v46.dart';
|
import 'package:nc_photos/use_case/compat/v46.dart';
|
||||||
import 'package:nc_photos/use_case/compat/v55.dart';
|
import 'package:nc_photos/use_case/compat/v55.dart';
|
||||||
|
@ -64,7 +65,7 @@ class _SplashState extends State<Splash> {
|
||||||
_isUpgrading = false;
|
_isUpgrading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
unawaited(_exit());
|
_exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -135,24 +136,30 @@ class _SplashState extends State<Splash> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _exit() async {
|
void _exit() {
|
||||||
_log.info("[_exit]");
|
_log.info("[_exit]");
|
||||||
final account = Pref().getCurrentAccount();
|
final account = Pref().getCurrentAccount();
|
||||||
if (isNeedSetup()) {
|
if (isNeedSetup()) {
|
||||||
unawaited(Navigator.pushReplacementNamed(context, Setup.routeName));
|
Navigator.pushReplacementNamed(context, Setup.routeName);
|
||||||
} else if (account == null) {
|
} else if (account == null) {
|
||||||
unawaited(Navigator.pushReplacementNamed(context, SignIn.routeName));
|
Navigator.pushReplacementNamed(context, SignIn.routeName);
|
||||||
} else {
|
} else {
|
||||||
unawaited(
|
Navigator.of(context)
|
||||||
Navigator.pushReplacementNamed(context, Home.routeName,
|
.pushReplacementProtected(Home.routeName,
|
||||||
arguments: HomeArguments(account)),
|
arguments: HomeArguments(account))
|
||||||
);
|
.then((value) async {
|
||||||
if (getRawPlatform() == NpPlatform.android) {
|
if (getRawPlatform() == NpPlatform.android) {
|
||||||
final initialRoute = await Activity.consumeInitialRoute();
|
final initialRoute = await Activity.consumeInitialRoute();
|
||||||
if (initialRoute != null) {
|
if (initialRoute != null) {
|
||||||
unawaited(Navigator.pushNamed(context, initialRoute));
|
unawaited(Navigator.pushNamed(context, initialRoute));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}).onError<ProtectedPageAuthException>((_, __) async {
|
||||||
|
_log.warning("[_exit] Auth failed");
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
_exit();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -277,7 +277,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.3"
|
version: "1.6.3"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
|
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
|
||||||
|
@ -814,6 +814,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
|
local_auth:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: local_auth
|
||||||
|
sha256: "280421b416b32de31405b0a25c3bd42dfcef2538dfbb20c03019e02a5ed55ed0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
local_auth_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_android
|
||||||
|
sha256: e0e5b1ea247c5a0951c13a7ee13dc1beae69750e6a2e1910d1ed6a3cd4d56943
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.38"
|
||||||
|
local_auth_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_darwin
|
||||||
|
sha256: e424ebf90d5233452be146d4a7da4bcd7a70278b67791592f3fde1bda8eef9e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
|
local_auth_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_platform_interface
|
||||||
|
sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.10"
|
||||||
|
local_auth_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_windows
|
||||||
|
sha256: "505ba3367ca781efb1c50d3132e44a2446bccc4163427bc203b9b4d8994d97ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.10"
|
||||||
logger:
|
logger:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1842,18 +1882,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c"
|
sha256: f2add6fa510d3ae152903412227bda57d0d5a8da61d2c39c1fb022c9429a41c0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.4"
|
version: "5.0.6"
|
||||||
win32_registry:
|
win32_registry:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32_registry
|
name: win32_registry
|
||||||
sha256: "1c52f994bdccb77103a6231ad4ea331a244dbcef5d1f37d8462f713143b0bfae"
|
sha256: e4506d60b7244251bc59df15656a3093501c37fb5af02105a944d73eb95be4c9
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.1"
|
||||||
wkt_parser:
|
wkt_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -46,6 +46,7 @@ dependencies:
|
||||||
url: https://gitlab.com/nkming2/dart-copy-with
|
url: https://gitlab.com/nkming2/dart-copy-with
|
||||||
path: copy_with
|
path: copy_with
|
||||||
ref: copy_with-1.3.0
|
ref: copy_with-1.3.0
|
||||||
|
crypto: ^3.0.3
|
||||||
devicelocale: ^0.6.0
|
devicelocale: ^0.6.0
|
||||||
device_info_plus: ^9.0.1
|
device_info_plus: ^9.0.1
|
||||||
draggable_scrollbar:
|
draggable_scrollbar:
|
||||||
|
@ -83,6 +84,7 @@ dependencies:
|
||||||
path: library
|
path: library
|
||||||
intl: 0.18.1
|
intl: 0.18.1
|
||||||
kiwi: ^4.1.0
|
kiwi: ^4.1.0
|
||||||
|
local_auth: ^2.2.0
|
||||||
logging: ^1.2.0
|
logging: ^1.2.0
|
||||||
memory_info: ^0.0.4
|
memory_info: ^0.0.4
|
||||||
mime: ^1.0.5
|
mime: ^1.0.5
|
||||||
|
|
|
@ -82,4 +82,6 @@ extension ListExtension<T> on List<T> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<T> added(T value) => toList()..add(value);
|
||||||
}
|
}
|
||||||
|
|
16
np_common/lib/unique.dart
Normal file
16
np_common/lib/unique.dart
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import 'package:to_string/to_string.dart';
|
||||||
|
|
||||||
|
part 'unique.g.dart';
|
||||||
|
|
||||||
|
/// An unique value does not compare equal with others having the same value,
|
||||||
|
/// instead only the same instances are considered equal
|
||||||
|
@toString
|
||||||
|
class Unique<T> {
|
||||||
|
// no const!
|
||||||
|
Unique(this.value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final T value;
|
||||||
|
}
|
14
np_common/lib/unique.g.dart
Normal file
14
np_common/lib/unique.g.dart
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'unique.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// ToStringGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$UniqueToString on Unique {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "Unique {value: $value}";
|
||||||
|
}
|
||||||
|
}
|
15
np_common/test/unique_test.dart
Normal file
15
np_common/test/unique_test.dart
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import 'package:np_common/unique.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group("Unique", () {
|
||||||
|
test("same value", () {
|
||||||
|
expect(Unique(1) == Unique(1), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same instance", () {
|
||||||
|
final a = Unique(1);
|
||||||
|
expect(a == a, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue