Skip to content

Commit

Permalink
fix: Now allowing dialogs to open above UnlockChallengeWidget.
Browse files Browse the repository at this point in the history
Fixed it by making `AppLockStateNotifier` returning `AppLockState` instead of a `bool`.
  • Loading branch information
Skyost committed Jan 4, 2025
1 parent f83d479 commit caa65b1
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 128 deletions.
57 changes: 22 additions & 35 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import 'package:open_authenticator/app.dart';
import 'package:open_authenticator/firebase_options.dart';
import 'package:open_authenticator/i18n/translations.g.dart';
import 'package:open_authenticator/model/app_links.dart';
import 'package:open_authenticator/model/app_unlock/state.dart';
import 'package:open_authenticator/model/authentication/providers/email_link.dart';
import 'package:open_authenticator/model/authentication/providers/provider.dart';
import 'package:open_authenticator/model/settings/show_intro.dart';
Expand Down Expand Up @@ -269,37 +268,32 @@ class _RouteWidgetState extends ConsumerState<_RouteWidget> {
},
fireImmediately: true,
);
ref.listenManual(
appUnlockStateProvider,
(previous, next) async {
if (previous == next || next is! AsyncData<bool> || next.value) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) => handleAppLocked());
},
fireImmediately: true,
);
}
}

@override
Widget build(BuildContext context) => widget.rateMyApp
? RateMyAppBuilder(
onInitialized: (context, rateMyApp) {
if (rateMyApp.shouldOpenDialog) {
rateMyApp.showRateDialog(context);
}
},
rateMyApp: RateMyApp.customConditions(
appStoreIdentifier: Stores.appStoreIdentifier,
googlePlayIdentifier: Stores.googlePlayIdentifier,
conditions: [
SupportedPlatformsCondition(),
],
)..populateWithDefaultConditions(),
builder: (context) => widget.child,
)
: widget.child;
Widget build(BuildContext context) {
Widget child = UnlockChallengeWidget(
child: widget.child,
);
return widget.rateMyApp
? RateMyAppBuilder(
onInitialized: (context, rateMyApp) {
if (rateMyApp.shouldOpenDialog) {
rateMyApp.showRateDialog(context);
}
},
rateMyApp: RateMyApp.customConditions(
appStoreIdentifier: Stores.appStoreIdentifier,
googlePlayIdentifier: Stores.googlePlayIdentifier,
conditions: [
SupportedPlatformsCondition(),
],
)..populateWithDefaultConditions(),
builder: (context) => child,
)
: child;
}

/// Handles a login link.
Future<void> handleLoginLink(Uri loginLink) async {
Expand Down Expand Up @@ -344,11 +338,4 @@ class _RouteWidgetState extends ConsumerState<_RouteWidget> {
TotpLimitDialog.showAndBlock(context);
}
}

/// Handles the app locked state.
Future<void> handleAppLocked() async {
if (mounted) {
UnlockChallengeOverlay.display(context);
}
}
}
16 changes: 16 additions & 0 deletions lib/model/app_unlock/method.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ sealed class AppUnlockMethod {
/// [context] is required so that we can interact with the user.
Future<Result> _tryUnlock(BuildContext context, Ref ref, UnlockReason reason);

/// The default app state.
AppLockState get defaultState => AppLockState.locked;

/// Triggered when this method has been chosen has the app unlock method.
/// [unlockResult] is the result of the [tryUnlock] call.
Future<void> onMethodChosen(Ref ref, {ResultSuccess? enableResult}) => Future.value();
Expand All @@ -41,6 +44,16 @@ sealed class AppUnlockMethod {
Future<void> onMethodChanged(Ref ref, {ResultSuccess? disableResult}) => Future.value();
}

/// Represents an app state.
enum AppLockState {
/// If the app is locked, waiting for unlock.
locked,
/// If the app has been unlocked.
unlocked,
/// If an unlock challenge has started.
unlockChallengedStarted;
}

/// Local authentication.
class LocalAuthenticationAppUnlockMethod extends AppUnlockMethod {
@override
Expand Down Expand Up @@ -153,6 +166,9 @@ class MasterPasswordAppUnlockMethod extends AppUnlockMethod {
class NoneAppUnlockMethod extends AppUnlockMethod {
@override
Future<Result> _tryUnlock(BuildContext context, Ref ref, UnlockReason reason) => Future.value(const ResultSuccess());

@override
AppLockState get defaultState => AppLockState.unlocked;
}

/// Configures the unlock reason for [UnlockChallenge]s.
Expand Down
11 changes: 6 additions & 5 deletions lib/model/app_unlock/state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@ import 'package:open_authenticator/model/settings/app_unlock_method.dart';
import 'package:open_authenticator/utils/result.dart';

/// The app unlock state state provider.
final appUnlockStateProvider = AsyncNotifierProvider<AppUnlockState, bool>(AppUnlockState.new);
final appLockStateProvider = AsyncNotifierProvider<AppLockStateNotifier, AppLockState>(AppLockStateNotifier.new);

/// Allows to get and set the app unlocked state.
class AppUnlockState extends AsyncNotifier<bool> {
class AppLockStateNotifier extends AsyncNotifier<AppLockState> {
@override
FutureOr<bool> build() async {
FutureOr<AppLockState> build() async {
AppUnlockMethod unlockMethod = await ref.read(appUnlockMethodSettingsEntryProvider.future);
return unlockMethod is NoneAppUnlockMethod;
return unlockMethod.defaultState;
}

/// Tries to unlock the app.
Future<Result> unlock(BuildContext context, {UnlockReason unlockReason = UnlockReason.openApp}) async {
state = AsyncData(AppLockState.unlockChallengedStarted);
Result result = await ref.read(appUnlockMethodSettingsEntryProvider.notifier).unlockWithCurrentMethod(context, unlockReason);
state = AsyncData(result is ResultSuccess ? true : false);
state = AsyncData(result is ResultSuccess ? AppLockState.unlocked : AppLockState.locked);
return result;
}
}
3 changes: 2 additions & 1 deletion lib/pages/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_authenticator/i18n/translations.g.dart';
import 'package:open_authenticator/main.dart';
import 'package:open_authenticator/model/app_unlock/method.dart';
import 'package:open_authenticator/model/app_unlock/state.dart';
import 'package:open_authenticator/model/crypto.dart';
import 'package:open_authenticator/model/settings/display_copy_button.dart';
Expand Down Expand Up @@ -159,7 +160,7 @@ class _HomePageBody extends ConsumerWidget {
AsyncValue<TotpList> totps = ref.watch(totpRepositoryProvider);
switch (totps) {
case AsyncData(:final value):
bool isUnlocked = ref.watch(appUnlockStateProvider).valueOrNull ?? false;
bool isUnlocked = ref.watch(appLockStateProvider).valueOrNull == AppLockState.unlocked;
bool displayCopyButton = ref.watch(displayCopyButtonSettingsEntryProvider).valueOrNull ?? true;
Widget child = value.isEmpty
? CustomScrollView(
Expand Down
144 changes: 57 additions & 87 deletions lib/widgets/unlock_challenge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,124 +4,94 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_authenticator/app.dart';
import 'package:open_authenticator/i18n/translations.g.dart';
import 'package:open_authenticator/model/app_unlock/method.dart';
import 'package:open_authenticator/model/app_unlock/state.dart';
import 'package:open_authenticator/utils/result.dart';
import 'package:open_authenticator/widgets/blur.dart';
import 'package:open_authenticator/widgets/snackbar_icon.dart';
import 'package:open_authenticator/widgets/title.dart';

/// An overlay that is shown waiting for the user to solve the unlock challenge.
class UnlockChallengeOverlay {
/// The current overlay entry, if inserted.
static OverlayEntry? _currentEntry;

/// Displays the unlock challenge overlay.
static void display(BuildContext context) {
if (_currentEntry != null) {
return;
}
_currentEntry = OverlayEntry(
builder: (context) => _UnlockChallengeOverlayWidget(
onUnlock: () {
_currentEntry?.remove();
_currentEntry = null;
},
),
);
Overlay.of(context).insert(_currentEntry!);
}
}

/// The unlock challenge widget.
class _UnlockChallengeOverlayWidget extends ConsumerStatefulWidget {
/// Triggered when unlocked.
final VoidCallback? onUnlock;
class UnlockChallengeWidget extends ConsumerStatefulWidget {
/// The child widget.
final Widget child;

/// Creates a new unlock challenge route widget instance.
const _UnlockChallengeOverlayWidget({
this.onUnlock,
/// Creates a new unlock challenge widget instance.
const UnlockChallengeWidget({
super.key,
required this.child,
});

@override
ConsumerState<ConsumerStatefulWidget> createState() => _UnlockChallengeWidgetState();
}

/// The master password unlock route widget state.
class _UnlockChallengeWidgetState extends ConsumerState<_UnlockChallengeOverlayWidget> {
/// Whether the unlock challenge has started.
bool unlockChallengedStarted = false;

class _UnlockChallengeWidgetState extends ConsumerState<UnlockChallengeWidget> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
tryUnlockIfNeeded();
});
}

@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: Colors.transparent,
body: BlurWidget(
above: Center(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: TitleWidget(
textAlign: TextAlign.center,
textStyle: Theme.of(context).textTheme.headlineLarge,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
translations.appUnlock.widget.text(app: App.appName),
textAlign: TextAlign.center,
),
),
Align(
child: SizedBox(
width: math.min(MediaQuery.of(context).size.width, 300),
child: FilledButton.icon(
onPressed: unlockChallengedStarted ? null : tryUnlockIfNeeded,
label: Text(translations.appUnlock.widget.button),
icon: const Icon(Icons.key),
),
Widget build(BuildContext context) {
AsyncValue<AppLockState> appLockState = ref.watch(appLockStateProvider);
return switch (appLockState) {
AsyncData<AppLockState>(:final value) => value == AppLockState.unlocked
? widget.child
: Scaffold(
backgroundColor: Colors.transparent,
body: BlurWidget(
above: Center(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: TitleWidget(
textAlign: TextAlign.center,
textStyle: Theme.of(context).textTheme.headlineLarge,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
translations.appUnlock.widget.text(app: App.appName),
textAlign: TextAlign.center,
),
),
Align(
child: SizedBox(
width: math.min(MediaQuery.of(context).size.width, 300),
child: FilledButton.icon(
onPressed: value == AppLockState.unlockChallengedStarted ? null : tryUnlockIfNeeded,
label: Text(translations.appUnlock.widget.button),
icon: const Icon(Icons.key),
),
),
),
],
),
),
],
below: widget.child,
),
),
),
),
);
_ => widget.child,
};
}

/// Tries to unlock the app.
Future<void> tryUnlockIfNeeded() async {
bool isUnlocked = await ref.read(appUnlockStateProvider.future);
if (!mounted) {
return;
}
if (isUnlocked) {
widget.onUnlock?.call();
return;
}
setState(() => unlockChallengedStarted = true);
Result result = await ref.read(appUnlockStateProvider.notifier).unlock(context);
if (!mounted) {
AppLockState lockState = await ref.read(appLockStateProvider.future);
if (!mounted || lockState != AppLockState.locked) {
return;
}
setState(() => unlockChallengedStarted = false);
switch (result) {
case ResultSuccess():
widget.onUnlock?.call();
break;
case ResultError():
SnackBarIcon.showErrorSnackBar(context, text: translations.error.appUnlock);
break;
default:
break;
Result result = await ref.read(appLockStateProvider.notifier).unlock(context);
if (result is ResultError && mounted) {
SnackBarIcon.showErrorSnackBar(context, text: translations.error.appUnlock);
}
}
}

0 comments on commit caa65b1

Please sign in to comment.