Skip to content

Commit

Permalink
Merge branch 'main' into adr/api_endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
stevenj authored Oct 16, 2024
2 parents 625c1f5 + cc7a8b4 commit 1b03434
Show file tree
Hide file tree
Showing 24 changed files with 774 additions and 146 deletions.
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,
);
}

@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),
),
),
),
),
);
}
}
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

0 comments on commit 1b03434

Please sign in to comment.