Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cat-voices): lock/unlock keychain #1003

Merged
merged 19 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion catalyst_voices/lib/app/view/app_content.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:catalyst_voices/app/view/app_precache_image_assets.dart';
import 'package:catalyst_voices/app/view/app_session_listener.dart';
import 'package:catalyst_voices_brands/catalyst_voices_brands.dart';
import 'package:catalyst_voices_localization/catalyst_voices_localization.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -51,7 +52,9 @@ class AppContentState extends State<AppContent> {
),
builder: (context, child) {
return GlobalPrecacheImages(
child: child ?? const SizedBox.shrink(),
child: GlobalSessionListener(
child: child ?? const SizedBox.shrink(),
),
);
},
);
Expand Down
67 changes: 67 additions & 0 deletions catalyst_voices/lib/app/view/app_session_listener.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart';
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart';
import 'package:catalyst_voices_assets/catalyst_voices_assets.dart';
import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart';
import 'package:catalyst_voices_localization/catalyst_voices_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// Listens globally to a session and can show different
/// snackBars when a session changes.
class GlobalSessionListener extends StatelessWidget {
final Widget child;

const GlobalSessionListener({
super.key,
required this.child,
});

@override
Widget build(BuildContext context) {
return BlocListener<SessionBloc, SessionState>(
listenWhen: _listenToSessionChangesWhen,
listener: _onSessionChanged,
child: child,
);
}

bool _listenToSessionChangesWhen(SessionState prev, SessionState next) {
// We deliberately check if previous was guest because we don't
// want to show the snackbar after the registration is completed.
final keychainUnlocked =
prev is GuestSessionState && next is ActiveUserSessionState;

final keychainLocked =
prev is ActiveUserSessionState && next is GuestSessionState;

return keychainUnlocked || keychainLocked;
}

void _onSessionChanged(BuildContext context, SessionState state) {
if (state is ActiveUserSessionState) {
_onUnlockedKeychain(context);
} else if (state is GuestSessionState) {
_onLockedKeychain(context);
}
}

void _onUnlockedKeychain(BuildContext context) {
VoicesSnackBar(
type: VoicesSnackBarType.success,
behavior: SnackBarBehavior.floating,
icon: VoicesAssets.icons.lockOpen.buildIcon(),
title: context.l10n.unlockSnackbarTitle,
message: context.l10n.unlockSnackbarMessage,
).show(context);
}

void _onLockedKeychain(BuildContext context) {
VoicesSnackBar(
type: VoicesSnackBarType.error,
behavior: SnackBarBehavior.floating,
icon: VoicesAssets.icons.lockClosed.buildIcon(),
title: context.l10n.lockSnackbarTitle,
message: context.l10n.lockSnackbarMessage,
).show(context);
}
}
40 changes: 36 additions & 4 deletions catalyst_voices/lib/common/error_handler.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,46 @@
//ignore_for_file: one_member_abstracts

import 'dart:async';

import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart';
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart';
import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart';
import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// An interface of an abstract error handler.
abstract interface class ErrorHandler {
void handleError(Object error);
}

mixin ErrorHandlerStateMixin<T extends StatefulWidget> on State<T>
implements ErrorHandler {
/// A convenient mixin that subscribes to the [ErrorEmitter]
/// obtained from the [errorEmitter] and calls the [handleError].
///
/// After the widget is disposed the error stream is disposed too.
mixin ErrorHandlerStateMixin<E extends ErrorEmitter, T extends StatefulWidget>
on State<T> implements ErrorHandler {
StreamSubscription<Object>? _errorSub;

@override
void initState() {
super.initState();
_errorSub = errorEmitter.errorStream.listen(handleError);
}

@override
void dispose() {
unawaited(_errorSub?.cancel());
_errorSub = null;
super.dispose();
}

/// A method that can be overridden to provide a custom error emitter.
///
/// If this method is not overriden then the emitter of type [E]
/// must be provided in a widget tree so that context.read can find it.
E get errorEmitter => context.read<E>();

@override
void handleError(Object error) {
if (error is LocalizedException) {
Expand All @@ -19,7 +49,9 @@ mixin ErrorHandlerStateMixin<T extends StatefulWidget> on State<T>
}

void handleLocalizedException(LocalizedException exception) {
// TODO(damian-molinski): VoicesSnackBar does not support custom text yet.
const VoicesSnackBar(type: VoicesSnackBarType.error).show(context);
VoicesSnackBar(
type: VoicesSnackBarType.error,
message: exception.message(context),
).show(context);
}
}
171 changes: 171 additions & 0 deletions catalyst_voices/lib/pages/account/unlock_keychain_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import 'package:catalyst_voices/common/error_handler.dart';
import 'package:catalyst_voices/pages/registration/pictures/unlock_keychain_picture.dart';
import 'package:catalyst_voices/pages/registration/widgets/information_panel.dart';
import 'package:catalyst_voices/pages/registration/widgets/registration_stage_message.dart';
import 'package:catalyst_voices/widgets/widgets.dart';
import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart';
import 'package:catalyst_voices_localization/catalyst_voices_localization.dart';
import 'package:catalyst_voices_services/catalyst_voices_services.dart';
import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// A dialog which allows to unlock the session (keychain).
class UnlockKeychainDialog extends StatefulWidget {
const UnlockKeychainDialog({super.key});

static Future<void> show(BuildContext context) {
return VoicesDialog.show(
context: context,
routeSettings: const RouteSettings(name: '/unlock'),
builder: (context) => const UnlockKeychainDialog(),
barrierDismissible: false,
);
}
damian-molinski marked this conversation as resolved.
Show resolved Hide resolved

@override
State<UnlockKeychainDialog> createState() => _UnlockKeychainDialogState();
}

class _UnlockKeychainDialogState extends State<UnlockKeychainDialog>
with ErrorHandlerStateMixin<SessionBloc, UnlockKeychainDialog> {
final TextEditingController _passwordController = TextEditingController();
LocalizedException? _error;

@override
void dispose() {
_passwordController.dispose();
super.dispose();
}

@override
void handleError(Object error) {
setState(() {
_error = error is LocalizedException
? error
: const LocalizedUnlockPasswordException();
});
}

@override
Widget build(BuildContext context) {
return BlocListener<SessionBloc, SessionState>(
listener: _handleSessionChange,
child: VoicesTwoPaneDialog(
left: InformationPanel(
title: context.l10n.unlockDialogHeader,
picture: const UnlockKeychainPicture(),
),
right: _UnlockPasswordPanel(
controller: _passwordController,
error: _error,
onUnlock: _onUnlock,
),
),
);
}

void _handleSessionChange(BuildContext context, SessionState state) {
if (state is ActiveUserSessionState) {
Navigator.of(context).pop();
}
}

void _onUnlock() {
setState(() {
_error = null;
});

final password = _passwordController.text;
final unlockFactor = PasswordLockFactor(password);
context
.read<SessionBloc>()
.add(UnlockSessionEvent(unlockFactor: unlockFactor));
}
}

class _UnlockPasswordPanel extends StatelessWidget {
final TextEditingController controller;
final LocalizedException? error;
final VoidCallback onUnlock;

const _UnlockPasswordPanel({
required this.controller,
required this.error,
required this.onUnlock,
});

@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
RegistrationStageMessage(
title: Text(context.l10n.unlockDialogTitle),
subtitle: Text(context.l10n.unlockDialogContent),
),
const SizedBox(height: 24),
_UnlockPassword(
controller: controller,
error: error,
),
const Spacer(),
_Navigation(
onUnlock: onUnlock,
),
],
);
}
}

class _UnlockPassword extends StatelessWidget {
final TextEditingController controller;
final LocalizedException? error;

const _UnlockPassword({
required this.controller,
required this.error,
});

@override
Widget build(BuildContext context) {
return VoicesPasswordTextField(
controller: controller,
decoration: VoicesTextFieldDecoration(
labelText: context.l10n.unlockDialogHint,
errorText: error?.message(context),
hintText: context.l10n.passwordLabelText,
),
);
}
}

class _Navigation extends StatelessWidget {
final VoidCallback onUnlock;

const _Navigation({
required this.onUnlock,
});

@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: VoicesOutlinedButton(
onTap: () => Navigator.of(context).pop(),
child: Text(context.l10n.continueAsGuest),
),
),
const SizedBox(width: 10),
Expanded(
child: VoicesFilledButton(
onTap: onUnlock,
child: Text(context.l10n.confirmPassword),
),
),
],
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import 'package:catalyst_voices/pages/registration/pictures/task_picture.dart';
import 'package:catalyst_voices_brands/catalyst_voices_brands.dart';
import 'package:flutter/material.dart';

class UnlockKeychainPicture extends StatelessWidget {
const UnlockKeychainPicture({super.key});

@override
Widget build(BuildContext context) {
return TaskPicture(
child: TaskPictureIconBox(
child: AspectRatio(
aspectRatio: 101 / 42,
child: Container(
height: double.infinity,
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv0,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
damian-molinski marked this conversation as resolved.
Show resolved Hide resolved
}
}
18 changes: 5 additions & 13 deletions catalyst_voices/lib/pages/registration/registration_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,18 @@ class RegistrationDialog extends StatefulWidget {
}

class _RegistrationDialogState extends State<RegistrationDialog>
with ErrorHandlerStateMixin {
late final RegistrationCubit _cubit;
StreamSubscription<Object>? _errorSub;

@override
void initState() {
super.initState();
_cubit = Dependencies.instance.get<RegistrationCubit>();
_errorSub = _cubit.errorStream.listen(handleError);
}
with ErrorHandlerStateMixin<RegistrationCubit, RegistrationDialog> {
late final RegistrationCubit _cubit = Dependencies.instance.get();

@override
void dispose() {
unawaited(_errorSub?.cancel());
_errorSub = null;

unawaited(_cubit.close());
super.dispose();
}

@override
RegistrationCubit get errorEmitter => _cubit;

@override
Widget build(BuildContext context) {
return BlocProvider.value(
Expand Down
Loading
Loading