From edd29a3f9218eb71584e8faba2f12e0c4559fc85 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:11:07 +0700 Subject: [PATCH] fix(extensions): accept account forms with optional cancel actions --- lib/utils/extension_action_forms.dart | 248 +++++++++++++++++++------- test/extension_action_forms_test.dart | 194 ++++++++++++++++++-- 2 files changed, 372 insertions(+), 70 deletions(-) diff --git a/lib/utils/extension_action_forms.dart b/lib/utils/extension_action_forms.dart index b168fd78..9e1d509f 100644 --- a/lib/utils/extension_action_forms.dart +++ b/lib/utils/extension_action_forms.dart @@ -1,7 +1,10 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; /// Account input goes straight to the action, never into extension settings. Future?> runExtensionActionWithForms( @@ -75,10 +78,13 @@ class _AccountForm { value == null || (value is String && value.length <= max); static _AccountForm? parse(dynamic raw) { + final cancelAction = raw is Map ? raw['cancel_action'] : null; if (raw is! Map || raw['version'] != 1 || !_action(raw['submit_action']) || - (raw['cancel_action'] != null && !_action(raw['cancel_action'])) || + (cancelAction != null && + cancelAction != '' && + !_action(cancelAction)) || !_text(raw['title'], 256) || !_text(raw['description'], 4096)) { return null; @@ -120,7 +126,7 @@ class _AccountForm { } return _AccountForm( raw['submit_action'] as String, - raw['cancel_action'] as String?, + cancelAction == '' ? null : cancelAction as String?, raw['title'] as String?, raw['description'] as String?, validated, @@ -185,6 +191,183 @@ class _AccountFormDialogState extends State<_AccountFormDialog> { Navigator.pop(context, values); } + String? _validate(Map field, String? value) { + if (field['required'] == true && (value == null || value.isEmpty)) { + return context.l10n.extensionAccountRequiredValue; + } + if (field['type'] == 'number' && + value != null && + value.isNotEmpty && + num.tryParse(value)?.isFinite != true) { + return context.l10n.extensionAccountInvalidNumber; + } + return null; + } + + Future _chooseOption(Map field) async { + final key = field['key'] as String; + final value = await showAppBottomSheet( + context: context, + title: field['label'] as String? ?? key, + maxHeightFactor: 0.7, + builder: (sheetContext) => SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final option in (field['options'] as List).cast()) + AppSheetOption( + title: Text(option), + trailing: option == _choices[key] + ? const Icon(CupertinoIcons.check_mark) + : null, + onTap: () => Navigator.pop(sheetContext, option), + ), + ], + ), + ), + ); + if (mounted && value != null) setState(() => _choices[key] = value); + } + + Widget _field(Map field, int index) { + final key = field['key'] as String; + final label = field['label'] as String? ?? key; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final mornye = context.isMornye; + final last = index == widget.form.fields.length - 1; + final secret = field['type'] == 'password' || field['secret'] == true; + final keyboard = field['type'] == 'number' + ? const TextInputType.numberWithOptions(decimal: true, signed: true) + : TextInputType.text; + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ); + final decoration = InputDecoration( + labelText: label, + filled: true, + fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5), + border: border, + counterText: '', + ); + Widget input; + if (field['type'] == 'select') { + input = mornye + ? CupertinoButton( + onPressed: () => _chooseOption(field), + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(12), + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text( + _choices[key]!, + style: theme.textTheme.bodyLarge, + ), + ), + Icon( + CupertinoIcons.chevron_up_chevron_down, + size: 16, + color: colors.onSurfaceVariant, + ), + ], + ), + ) + : DropdownButtonFormField( + initialValue: _choices[key], + isExpanded: true, + decoration: decoration, + items: (field['options'] as List) + .cast() + .map( + (option) => + DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + if (value != null) setState(() => _choices[key] = value); + }, + ); + } else if (mornye) { + input = FormField( + validator: (value) => _validate(field, value), + builder: (state) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CupertinoTextField( + controller: _controllers[key], + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(12), + ), + style: theme.textTheme.bodyLarge, + obscureText: secret, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + maxLength: 4096, + keyboardType: keyboard, + textInputAction: last + ? TextInputAction.done + : TextInputAction.next, + onChanged: state.didChange, + onSubmitted: (_) => + last ? _submit() : FocusScope.of(context).nextFocus(), + ), + if (state.errorText != null) + Padding( + padding: const EdgeInsets.only(left: 4, top: 6), + child: Text( + state.errorText!, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.error, + ), + ), + ), + ], + ), + ); + } else { + input = TextFormField( + controller: _controllers[key], + obscureText: secret, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + maxLength: 4096, + keyboardType: keyboard, + textInputAction: last ? TextInputAction.done : TextInputAction.next, + onFieldSubmitted: (_) => + last ? _submit() : FocusScope.of(context).nextFocus(), + decoration: decoration, + validator: (value) => _validate(field, value), + ); + } + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (mornye) ...[ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + Semantics(label: label, child: input), + ], + ), + ); + } + @override Widget build(BuildContext context) => AppAlertDialog( title: Text(widget.form.title ?? context.l10n.extensionAccountTitle), @@ -194,68 +377,13 @@ class _AccountFormDialogState extends State<_AccountFormDialog> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (widget.form.description != null) + if (widget.form.description?.isNotEmpty == true) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text(widget.form.description!), ), - for (final field in widget.form.fields) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: field['type'] == 'select' - ? DropdownButtonFormField( - initialValue: _choices[field['key']], - decoration: InputDecoration( - labelText: field['label'] as String?, - ), - items: (field['options'] as List) - .cast() - .map( - (option) => DropdownMenuItem( - value: option, - child: Text(option), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - setState( - () => _choices[field['key'] as String] = value, - ); - } - }, - ) - : TextFormField( - controller: _controllers[field['key']], - obscureText: - field['type'] == 'password' || - field['secret'] == true, - autocorrect: false, - enableSuggestions: false, - enableIMEPersonalizedLearning: false, - maxLength: 4096, - keyboardType: field['type'] == 'number' - ? TextInputType.number - : TextInputType.text, - decoration: InputDecoration( - labelText: field['label'] as String?, - counterText: '', - ), - validator: (value) { - if (field['required'] == true && - (value == null || value.isEmpty)) { - return context.l10n.extensionAccountRequiredValue; - } - if (field['type'] == 'number' && - value != null && - value.isNotEmpty && - (num.tryParse(value)?.isFinite != true)) { - return context.l10n.extensionAccountInvalidNumber; - } - return null; - }, - ), - ), + for (var i = 0; i < widget.form.fields.length; i++) + _field(widget.form.fields[i], i), ], ), ), diff --git a/test/extension_action_forms_test.dart b/test/extension_action_forms_test.dart index 5d6d52da..ca133ab1 100644 --- a/test/extension_action_forms_test.dart +++ b/test/extension_action_forms_test.dart @@ -1,10 +1,20 @@ import 'dart:convert'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/extension_action_forms.dart'; +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + void main() { const channel = MethodChannel('com.zarz.spotiflac/backend'); tearDown( @@ -14,17 +24,22 @@ void main() { Future showHost( WidgetTester tester, - Future Function(BuildContext) action, - ) { + Future Function(BuildContext) action, { + bool mornye = false, + }) { return tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: Builder( - builder: (context) => ElevatedButton( - onPressed: () => action(context), - child: const Text('Connect'), + ProviderScope( + overrides: [settingsProvider.overrideWith(_Settings.new)], + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => action(context), + child: const Text('Connect'), + ), ), ), ), @@ -32,6 +47,165 @@ void main() { ); } + for (final mornye in [false, true]) { + testWidgets('empty cancel action allows account setup ($mornye)', ( + tester, + ) async { + final actions = []; + final inputs = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = call.arguments as Map; + actions.add(args['action'] as String); + if (args['arguments_json'] != null) { + inputs.add( + Map.from( + (jsonDecode(args['arguments_json'] as String) as List).single + as Map, + ), + ); + return '{"success":true,"state":"active"}'; + } + return jsonEncode({ + 'success': true, + 'result': { + 'success': true, + 'byoa_form': { + 'version': 1, + 'title': 'Connect your account', + 'submit_action': 'connectAccount', + 'cancel_action': '', + 'fields': [ + { + 'key': 'region', + 'label': 'Account region', + 'type': 'select', + 'options': ['Europe', 'Asia'], + }, + { + 'key': 'port', + 'label': 'Port', + 'type': 'number', + 'required': true, + }, + { + 'key': 'password', + 'label': 'Password', + 'type': 'password', + 'required': true, + }, + ], + }, + }, + }); + }); + Map? result; + await showHost(tester, (context) async { + result = await runExtensionActionWithForms( + context, + 'example.account', + 'connectAccount', + ); + }, mornye: mornye); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect(find.text('Connect your account'), findsOneWidget); + expect(result, isNull); + + final fields = find.byType(mornye ? CupertinoTextField : TextFormField); + await tester.enterText(fields.first, 'invalid'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('Enter a valid number.'), findsOneWidget); + expect(find.text('Enter a value.'), findsOneWidget); + expect(actions, ['connectAccount']); + + await tester.enterText(fields.first, '443'); + await tester.enterText(fields.last, 'fixture-password'); + if (mornye) { + final text = tester.widget(fields.last); + expect(text.obscureText, isTrue); + expect(text.enableIMEPersonalizedLearning, isFalse); + await tester.tap(find.text('Europe')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Asia')); + await tester.pumpAndSettle(); + } else { + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Asia').last); + await tester.pumpAndSettle(); + } + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(result!['state'], 'active'); + expect(inputs, [ + {'region': 'Asia', 'port': 443, 'password': 'fixture-password'}, + ]); + expect(actions, ['connectAccount', 'connectAccount']); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('empty cancel action does not invoke an unnamed action', ( + tester, + ) async { + final actions = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + actions.add((call.arguments as Map)['action'] as String); + return '{"success":true,"action_form":{"version":1,' + '"submit_action":"loginAccount","cancel_action":"",' + '"fields":[{"key":"password","type":"password"}]}}'; + }); + await showHost(tester, (context) async { + await runExtensionActionWithForms( + context, + 'example.account', + 'loginAccount', + ); + }); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(actions, ['loginAccount']); + }); + + testWidgets('nonempty malformed cancel actions are still rejected', ( + tester, + ) async { + for (final cancelAction in ['not an action', 42]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => jsonEncode({ + 'success': true, + 'action_form': { + 'version': 1, + 'submit_action': 'loginAccount', + 'cancel_action': cancelAction, + 'fields': [ + {'key': 'password', 'type': 'password'}, + ], + }, + }), + ); + Map? result; + await showHost(tester, (context) async { + result = await runExtensionActionWithForms( + context, + 'example.account', + 'loginAccount', + ); + }); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect(result!['success'], isFalse); + expect(find.byType(TextFormField), findsNothing); + } + }); + testWidgets('password form submits transient input and clears its text', ( tester, ) async {