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 15 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
44 changes: 38 additions & 6 deletions catalyst_voices/lib/common/error_handler.dart
Original file line number Diff line number Diff line change
@@ -1,25 +1,57 @@
//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) {
handleLocalizedException(error);
_handleLocalizedException(error);
}
}

void handleLocalizedException(LocalizedException exception) {
// TODO(damian-molinski): VoicesSnackBar does not support custom text yet.
const VoicesSnackBar(type: VoicesSnackBarType.error).show(context);
void _handleLocalizedException(LocalizedException exception) {
damian-molinski marked this conversation as resolved.
Show resolved Hide resolved
VoicesSnackBar(
type: VoicesSnackBarType.error,
message: exception.message(context),
).show(context);
}
}
168 changes: 168 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,168 @@
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/snackbar/voices_snackbar.dart';
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart';
import 'package:catalyst_voices/widgets/widgets.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: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 StatelessWidget {
const UnlockKeychainDialog({super.key});

@override
Widget build(BuildContext context) {
return VoicesTwoPaneDialog(
left: InformationPanel(
title: context.l10n.unlockDialogHeader,
picture: const UnlockKeychainPicture(),
),
right: const _UnlockPasswordPanel(),
);
}

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
}

class _UnlockPasswordPanel extends StatefulWidget {
damian-molinski marked this conversation as resolved.
Show resolved Hide resolved
const _UnlockPasswordPanel();

@override
State<_UnlockPasswordPanel> createState() => _UnlockPasswordPanelState();
}

class _UnlockPasswordPanelState extends State<_UnlockPasswordPanel>
with ErrorHandlerStateMixin<SessionBloc, _UnlockPasswordPanel> {
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: (context, state) {
if (state is ActiveUserSessionState) {
VoicesSnackBar(
type: VoicesSnackBarType.success,
behavior: SnackBarBehavior.floating,
icon: VoicesAssets.icons.lockOpen.buildIcon(),
title: context.l10n.unlockSnackbarTitle,
message: context.l10n.unlockSnackbarMessage,
).show(context);

Navigator.of(context).pop();
}
},
damian-molinski marked this conversation as resolved.
Show resolved Hide resolved
child: 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: _passwordController,
error: _error,
),
const Spacer(),
_Navigation(
onUnlock: _onUnlock,
),
],
),
);
}

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

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

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,21 @@
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: DecoratedBox(
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import 'dart:async';

import 'package:catalyst_voices/pages/account/unlock_keychain_dialog.dart';
import 'package:catalyst_voices/widgets/buttons/voices_filled_button.dart';
import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart';
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';
Expand Down Expand Up @@ -53,7 +58,15 @@ class _LockButton extends StatelessWidget {
return VoicesIconButton.filled(
style: const ButtonStyle(shape: WidgetStatePropertyAll(CircleBorder())),
onTap: () {
context.read<SessionBloc>().add(const GuestSessionEvent());
context.read<SessionBloc>().add(const LockSessionEvent());

VoicesSnackBar(
type: VoicesSnackBarType.error,
behavior: SnackBarBehavior.floating,
icon: VoicesAssets.icons.lockClosed.buildIcon(),
title: context.l10n.lockSnackbarTitle,
message: context.l10n.lockSnackbarMessage,
).show(context);
},
child: VoicesAssets.icons.lockClosed.buildIcon(),
);
Expand All @@ -67,9 +80,7 @@ class _UnlockButton extends StatelessWidget {
Widget build(BuildContext context) {
return VoicesFilledButton(
trailing: VoicesAssets.icons.lockOpen.buildIcon(),
onTap: () {
context.read<SessionBloc>().add(const ActiveUserSessionEvent());
},
onTap: () => unawaited(UnlockKeychainDialog.show(context)),
child: Text(context.l10n.unlock),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import 'dart:async';

import 'package:catalyst_voices/pages/account/account_popup.dart';
import 'package:catalyst_voices/routes/routing/account_route.dart';
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart';
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart';
import 'package:catalyst_voices/widgets/widgets.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';
Expand All @@ -21,15 +24,31 @@ class SessionStateHeader extends StatelessWidget {
GuestSessionState() => const _GuestButton(),
ActiveUserSessionState(:final user) => AccountPopup(
avatarLetter: user.acronym,
onLockAccountTap: () => debugPrint('Lock account'),
onProfileKeychainTap: () => unawaited(
const AccountRoute().push<void>(context),
),
onLockAccountTap: () => _onLockAccount(context),
onProfileKeychainTap: () => _onSeeProfile(context),
),
};
},
);
}

void _onLockAccount(BuildContext context) {
context.read<SessionBloc>().add(const LockSessionEvent());

VoicesSnackBar(
type: VoicesSnackBarType.error,
behavior: SnackBarBehavior.floating,
icon: VoicesAssets.icons.lockClosed.buildIcon(),
title: context.l10n.lockSnackbarTitle,
message: context.l10n.lockSnackbarMessage,
).show(context);
damian-molinski marked this conversation as resolved.
Show resolved Hide resolved
}

void _onSeeProfile(BuildContext context) {
unawaited(
const AccountRoute().push<void>(context),
);
}
}

class _GuestButton extends StatelessWidget {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class StandardLinksPageFooter extends StatelessWidget {

@override
Widget build(BuildContext context) {
// TODO(damian): implement proper routing actions once we have them
// TODO(damian-molinski): implement proper routing actions once we have them
return LinksPageFooter(
upperChildren: [
LinkText(
Expand Down
Loading
Loading