diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index d526fa9d..0193fbb7 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -39,6 +39,7 @@ import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../application/pending_create_task.dart'; import '../widgets/resolve_file_tree.dart'; +import '../widgets/resolve_task_actions.dart'; class CreateTaskWindowPage extends ConsumerStatefulWidget { const CreateTaskWindowPage({super.key, this.windowController, this.initialTask}); @@ -1140,16 +1141,12 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), actions: [ - SecondaryButton( - onPressed: submitting ? null : () => closeOverlay(dialogContext, false), - child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))), - ), - AppLoadingButton( - key: const ValueKey('resolve-create-button'), - onPressed: submit, - loading: submitting, - variant: AppLoadingButtonVariant.primary, - child: Text(dialogContext.l10n.createAction, maxLines: 1, softWrap: false), + flutter.Flexible( + child: ResolveTaskActions( + submitting: submitting, + onCancel: () => closeOverlay(dialogContext, false), + onCreate: submit, + ), ), ], ); diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart new file mode 100644 index 00000000..e9b6dc37 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart @@ -0,0 +1,50 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter/widgets.dart' as flutter; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; + +class ResolveTaskActions extends StatelessWidget { + const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate}); + + final bool submitting; + final VoidCallback onCancel; + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + // Size both actions from the wider translated label, including while the + // create button replaces its label with a loader. + return IntrinsicWidth( + child: flutter.Row( + mainAxisSize: MainAxisSize.min, + children: [ + flutter.Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth), + child: SecondaryButton( + key: const ValueKey('resolve-cancel-button'), + onPressed: submitting ? null : onCancel, + child: Text(context.l10n.cancel, maxLines: 1, softWrap: false), + ), + ), + ), + const SizedBox(width: AppDesignTokens.space8), + flutter.Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth), + child: AppLoadingButton( + key: const ValueKey('resolve-create-button'), + onPressed: onCreate, + loading: submitting, + variant: AppLoadingButtonVariant.primary, + child: Text(context.l10n.createAction, maxLines: 1, softWrap: false), + ), + ), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/shared/theme/app_design_tokens.dart b/ui/flutter/lib/shared/theme/app_design_tokens.dart index 3deed31c..615fb139 100644 --- a/ui/flutter/lib/shared/theme/app_design_tokens.dart +++ b/ui/flutter/lib/shared/theme/app_design_tokens.dart @@ -10,6 +10,7 @@ class AppDesignTokens { static const double contentHeaderHeight = 48; static const double taskRowHeight = 72; static const double controlRadius = 4; + static const double dialogActionMinWidth = 100; static const double checkboxSize = 18; static const double checkboxLabelGap = 10; static const double taskDetailsDrawerMinWidth = 440; diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index b1585db3..61083a81 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -376,9 +376,7 @@ void main() { expect(submitted?.opts?.name, isEmpty); }); - testWidgets('resolved task keeps rename empty and uses a single-line intrinsic create action', ( - WidgetTester tester, - ) async { + testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async { tester.view.physicalSize = const Size(700, 700); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -422,6 +420,10 @@ void main() { expect(createText.data, 'Create'); expect(createText.maxLines, 1); expect(createText.softWrap, isFalse); + expect( + tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))), + tester.getSize(find.byKey(const ValueKey('resolve-create-button'))), + ); expect(tester.takeException(), isNull); }); diff --git a/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart new file mode 100644 index 00000000..4a8d5940 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/resolve_task_actions.dart'; +import 'package:gopeed/l10n/l10n.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final width in [390.0, 1024.0]) { + for (final language in ['en', 'zh', 'de']) { + testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async { + tester.view.physicalSize = Size(width, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var submitting = false; + var cancelled = false; + await tester.pumpWidget( + shad.ShadcnApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => Localizations.override( + context: context, + locale: Locale(language), + delegates: const [AppLocalizations.delegate], + child: AppComponentThemes( + child: Center( + child: StatefulBuilder( + builder: (context, setState) => shad.AlertDialog( + padding: const EdgeInsets.all(18), + content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100), + actions: [ + Flexible( + child: ResolveTaskActions( + submitting: submitting, + onCancel: () => cancelled = true, + onCreate: () => setState(() => submitting = true), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final cancel = find.byKey(const ValueKey('resolve-cancel-button')); + final create = find.byKey(const ValueKey('resolve-create-button')); + final cancelRect = tester.getRect(cancel); + final createRect = tester.getRect(create); + expect(cancelRect.size, createRect.size); + expect(cancelRect.center.dy, createRect.center.dy); + expect(cancelRect.left, greaterThanOrEqualTo(0)); + expect(createRect.right, lessThanOrEqualTo(width)); + expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget); + await tester.tap(cancel); + expect(cancelled, isTrue); + await tester.tap(create); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.widget(create).loading, isTrue); + expect(tester.widget(cancel).onPressed, isNull); + expect(tester.getRect(cancel), cancelRect); + expect(tester.getRect(create), createRect); + expect(tester.takeException(), isNull); + }); + } + } +}