diff --git a/catalyst_voices/lib/widgets/avatars/voices_avatar.dart b/catalyst_voices/lib/widgets/avatars/voices_avatar.dart index b2570c6ed5..8989efd60a 100644 --- a/catalyst_voices/lib/widgets/avatars/voices_avatar.dart +++ b/catalyst_voices/lib/widgets/avatars/voices_avatar.dart @@ -21,6 +21,9 @@ class VoicesAvatar extends StatelessWidget { /// The size of the avatar, expressed as the radius (half the diameter). final double radius; + /// The border around the widget. + final Border? border; + /// The callback called when the widget is tapped. final VoidCallback? onTap; @@ -32,15 +35,21 @@ class VoicesAvatar extends StatelessWidget { this.backgroundColor, this.padding = const EdgeInsets.all(8), this.radius = 20, + this.border, this.onTap, }); @override Widget build(BuildContext context) { - return CircleAvatar( - radius: radius, - backgroundColor: - backgroundColor ?? Theme.of(context).colorScheme.primaryContainer, + return Container( + width: radius * 2, + height: radius * 2, + decoration: BoxDecoration( + color: + backgroundColor ?? Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(radius), + border: border, + ), child: Material( type: MaterialType.transparency, child: InkWell( diff --git a/catalyst_voices/lib/widgets/modals/voices_alert_dialog.dart b/catalyst_voices/lib/widgets/modals/voices_alert_dialog.dart new file mode 100644 index 0000000000..654260c71f --- /dev/null +++ b/catalyst_voices/lib/widgets/modals/voices_alert_dialog.dart @@ -0,0 +1,163 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +/// An alert dialog similar to [AlertDialog] +/// but customized to the project needs. +/// +/// On extra small screens (mobile) it will fill the whole screen width, +/// on larger screens it will take [_width] amount +/// of horizontal space and be centered. +/// +/// The close (x) button will appear if the dialog [isDismissible]. +class VoicesAlertDialog extends StatelessWidget { + static const double _width = 360; + + /// The widget which appears at the top of the dialog next to the (x) button. + /// Usually a [Text] widget. + final Widget? title; + + /// The widget which appears below the [title], + /// usually a [VoicesAvatar] widget. + final Widget? icon; + + /// The widget appears below the [icon], is less prominent than the [title]. + /// Usually a [Text] widget. + final Widget? subtitle; + + /// The widget appears below the [subtitle], usually a [Text] widget, + /// can be multiline. + final Widget? content; + + /// The list of widgets which appear at the bottom of the dialog, + /// usually [VoicesFilledButton] or [VoicesTextButton]. + /// + /// [buttons] are separated with 8px of padding between each other + /// so you don't need to add your own padding. + final List buttons; + + /// Whether to show a (x) close button. + final bool isDismissible; + + const VoicesAlertDialog({ + super.key, + this.title, + this.icon, + this.subtitle, + this.content, + this.buttons = const [], + this.isDismissible = true, + }); + + @override + Widget build(BuildContext context) { + final title = this.title; + final icon = this.icon; + final subtitle = this.subtitle; + final content = this.content; + + return ResponsiveBuilder( + xs: double.infinity, + other: _width, + builder: (context, width) { + return Dialog( + alignment: Alignment.center, + child: SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.only(top: 10, bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null || isDismissible) + Row( + children: [ + // if widget is dismissible then show an invisible + // close button to reserve space on this side of the + // row so that the title is centered + if (isDismissible) + const Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: _CloseButton(), + ), + Expanded( + child: DefaultTextStyle( + style: Theme.of(context).textTheme.titleLarge!, + textAlign: TextAlign.center, + child: title ?? const SizedBox.shrink(), + ), + ), + if (isDismissible) const _CloseButton(), + ], + ), + if (icon != null) + Padding( + padding: const EdgeInsets.only( + top: 24, + left: 20, + right: 20, + ), + child: Center(child: icon), + ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only( + top: 16, + left: 20, + right: 20, + ), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.titleSmall!, + textAlign: TextAlign.center, + child: subtitle, + ), + ), + if (content != null) + Padding( + padding: const EdgeInsets.only( + top: 16, + left: 20, + right: 20, + ), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!, + textAlign: TextAlign.center, + child: content, + ), + ), + if (buttons.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + ...buttons.separatedBy(const SizedBox(height: 8)), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _CloseButton extends StatelessWidget { + const _CloseButton(); + + @override + Widget build(BuildContext context) { + return XButton( + onTap: () => Navigator.of(context).pop(), + ); + } +} diff --git a/catalyst_voices/lib/widgets/modals/voices_desktop_dialog.dart b/catalyst_voices/lib/widgets/modals/voices_desktop_dialog.dart index 3016043a40..8318dbbe20 100644 --- a/catalyst_voices/lib/widgets/modals/voices_desktop_dialog.dart +++ b/catalyst_voices/lib/widgets/modals/voices_desktop_dialog.dart @@ -62,9 +62,6 @@ class VoicesDesktopPanelsDialog extends StatelessWidget { Expanded( child: Container( padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: theme.colors.elevationsOnSurfaceNeutralLv1White, - ), child: right, ), ), diff --git a/catalyst_voices/lib/widgets/modals/voices_dialog.dart b/catalyst_voices/lib/widgets/modals/voices_dialog.dart index da2b7de363..95b0cd4976 100644 --- a/catalyst_voices/lib/widgets/modals/voices_dialog.dart +++ b/catalyst_voices/lib/widgets/modals/voices_dialog.dart @@ -4,13 +4,17 @@ import 'package:flutter/material.dart'; /// meant to be extended. abstract final class VoicesDialog { /// Encapsulates single entry point. - static Future show( - BuildContext context, { + static Future show({ + required BuildContext context, required WidgetBuilder builder, + RouteSettings? routeSettings, + bool barrierDismissible = true, }) { return showDialog( context: context, builder: builder, + routeSettings: routeSettings, + barrierDismissible: barrierDismissible, ); } } diff --git a/catalyst_voices/lib/widgets/modals/voices_info_dialog.dart b/catalyst_voices/lib/widgets/modals/voices_info_dialog.dart index 1bb023866f..d81bd7b909 100644 --- a/catalyst_voices/lib/widgets/modals/voices_info_dialog.dart +++ b/catalyst_voices/lib/widgets/modals/voices_info_dialog.dart @@ -11,7 +11,7 @@ import 'package:flutter/material.dart'; /// Call [VoicesDialog.show] with [VoicesDesktopInfoDialog] in order /// to show it. class VoicesDesktopInfoDialog extends StatelessWidget { - final String title; + final Widget title; const VoicesDesktopInfoDialog({ super.key, @@ -26,10 +26,10 @@ class VoicesDesktopInfoDialog extends StatelessWidget { left: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: theme.textTheme.titleLarge - ?.copyWith(color: theme.colors.textOnPrimary), + DefaultTextStyle( + style: theme.textTheme.titleLarge! + .copyWith(color: theme.colors.textOnPrimary), + child: title, ), ], ), diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 7b0172eefd..0b9e550ea8 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -39,6 +39,7 @@ export 'menu/voices_list_tile.dart'; export 'menu/voices_menu.dart'; export 'menu/voices_node_menu.dart'; export 'menu/voices_wallet_tile.dart'; +export 'modals/voices_alert_dialog.dart'; export 'modals/voices_desktop_dialog.dart'; export 'modals/voices_dialog.dart'; export 'modals/voices_info_dialog.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart index dae4e3f1da..52149626e6 100644 --- a/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart +++ b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart @@ -315,7 +315,7 @@ ThemeData _buildThemeData( barrierColor: const Color(0x612A3D61), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.hardEdge, - backgroundColor: voicesColorScheme.onSurfaceNeutralOpaqueLv0, + backgroundColor: voicesColorScheme.elevationsOnSurfaceNeutralLv1White, ), listTileTheme: ListTileThemeData( shape: const StadiumBorder(), diff --git a/catalyst_voices/test/widgets/avatars/voices_avatar_test.dart b/catalyst_voices/test/widgets/avatars/voices_avatar_test.dart index 1c98c6f849..757f804687 100644 --- a/catalyst_voices/test/widgets/avatars/voices_avatar_test.dart +++ b/catalyst_voices/test/widgets/avatars/voices_avatar_test.dart @@ -14,13 +14,12 @@ void main() { ), ); - // Verify if CircleAvatar is rendered with the correct default radius. - final circleAvatarFinder = find.byType(CircleAvatar); - expect(circleAvatarFinder, findsOneWidget); + // Verify if Container is rendered with the correct default radius. + final containerFinder = find.byType(Container); + expect(containerFinder, findsOneWidget); - final circleAvatarWidget = - tester.widget(circleAvatarFinder); - expect(circleAvatarWidget.radius, 20); + final containerWidget = tester.widget(containerFinder); + expect(containerWidget.constraints?.maxWidth, 40); // Verify the icon is rendered. expect(find.byIcon(Icons.person), findsOneWidget); @@ -40,11 +39,10 @@ void main() { ), ); - // Verify if CircleAvatar is rendered with the correct custom radius. - final circleAvatarFinder = find.byType(CircleAvatar); - final circleAvatarWidget = - tester.widget(circleAvatarFinder); - expect(circleAvatarWidget.radius, 30); + // Verify if Container is rendered with the correct custom radius. + final containerFinder = find.byType(Container); + final containerWidget = tester.widget(containerFinder); + expect(containerWidget.constraints?.maxWidth, 60); // Verify the Padding is applied correctly. final paddingFinder = find.ancestor( @@ -73,10 +71,12 @@ void main() { ); // Verify the background color is correctly applied. - final circleAvatarFinder = find.byType(CircleAvatar); - final circleAvatarWidget = - tester.widget(circleAvatarFinder); - expect(circleAvatarWidget.backgroundColor, backgroundColor); + final containerFinder = find.byType(Container); + final containerWidget = tester.widget(containerFinder); + expect( + (containerWidget.decoration! as BoxDecoration).color, + backgroundColor, + ); // Verify the foreground color is correctly applied to the icon. final iconThemeFinder = find.ancestor( @@ -132,10 +132,12 @@ void main() { ); // Verify the background color is from the theme's primaryContainer. - final circleAvatarFinder = find.byType(CircleAvatar); - final circleAvatarWidget = - tester.widget(circleAvatarFinder); - expect(circleAvatarWidget.backgroundColor, Colors.blueGrey); + final containerFinder = find.byType(Container); + final containerWidget = tester.widget(containerFinder); + expect( + (containerWidget.decoration! as BoxDecoration).color, + Colors.blueGrey, + ); // Verify the foreground color is from the theme's primary. final iconThemeFinder = find.byType(IconTheme); diff --git a/catalyst_voices/uikit_example/lib/examples/voices_avatar_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_avatar_example.dart index 53bbee07ec..5c1c42ae9a 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_avatar_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_avatar_example.dart @@ -22,6 +22,14 @@ class VoicesAvatarExample extends StatelessWidget { VoicesAvatar( icon: VoicesAssets.icons.check.buildIcon(), ), + VoicesAvatar( + backgroundColor: Colors.transparent, + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + icon: VoicesAssets.icons.check.buildIcon(), + ), VoicesAvatar( icon: const Text('A'), onTap: () {}, diff --git a/catalyst_voices/uikit_example/lib/examples/voices_modals_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_modals_example.dart index 304b0626ad..79aca47497 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_modals_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_modals_example.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; class VoicesModalsExample extends StatelessWidget { @@ -12,21 +16,64 @@ class VoicesModalsExample extends StatelessWidget { appBar: AppBar(title: const Text('Modals')), body: Padding( padding: const EdgeInsets.all(16), - child: Column( + child: Wrap( + spacing: 16, + runSpacing: 16, children: [ VoicesFilledButton( child: const Text('Desktop info dialog'), onTap: () async { await VoicesDialog.show( - context, + context: context, builder: (context) { return const VoicesDesktopInfoDialog( - title: 'Desktop modal', + title: Text('Desktop modal'), ); }, ); }, ), + VoicesFilledButton( + child: const Text('Alert Dialog'), + onTap: () => unawaited( + VoicesDialog.show( + context: context, + builder: (context) { + return VoicesAlertDialog( + title: const Text('WARNING'), + icon: VoicesAvatar( + radius: 40, + backgroundColor: Colors.transparent, + icon: VoicesAssets.icons.exclamation.buildIcon( + size: 36, + color: Theme.of(context).colors.iconsError, + ), + border: Border.all( + color: Theme.of(context).colors.iconsError!, + width: 3, + ), + ), + subtitle: const Text('ACCOUNT CREATION INCOMPLETE!'), + content: const Text( + 'If attempt to leave without creating your keychain' + ' - account creation will be incomplete.\n\nYou are' + ' not able to login without completing your keychain.', + ), + buttons: [ + VoicesFilledButton( + child: const Text('Continue keychain creation'), + onTap: () => Navigator.of(context).pop(), + ), + VoicesTextButton( + child: const Text('Cancel anyway'), + onTap: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ), + ), + ), ], ), ),