mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(extensions): accept account forms with optional cancel actions
This commit is contained in:
1 parent
f035538778
commit
edd29a3f92
2 files changed
+372
-70
No files matched your search
@@ -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<Map<String, dynamic>?> 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<String, dynamic> 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<void> _chooseOption(Map<String, dynamic> field) async {
|
||||
final key = field['key'] as String;
|
||||
final value = await showAppBottomSheet<String>(
|
||||
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<String>())
|
||||
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<String, dynamic> 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<String>(
|
||||
initialValue: _choices[key],
|
||||
isExpanded: true,
|
||||
decoration: decoration,
|
||||
items: (field['options'] as List)
|
||||
.cast<String>()
|
||||
.map(
|
||||
(option) =>
|
||||
DropdownMenuItem(value: option, child: Text(option)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _choices[key] = value);
|
||||
},
|
||||
);
|
||||
} else if (mornye) {
|
||||
input = FormField<String>(
|
||||
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<String>(
|
||||
initialValue: _choices[field['key']],
|
||||
decoration: InputDecoration(
|
||||
labelText: field['label'] as String?,
|
||||
),
|
||||
items: (field['options'] as List)
|
||||
.cast<String>()
|
||||
.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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<void> showHost(
|
||||
WidgetTester tester,
|
||||
Future<void> Function(BuildContext) action,
|
||||
) {
|
||||
Future<void> 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 = <String>[];
|
||||
final inputs = <Map<String, dynamic>>[];
|
||||
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<String, dynamic>.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<String, dynamic>? 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<CupertinoTextField>(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<String>));
|
||||
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 = <String>[];
|
||||
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<String, dynamic>? 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 {
|
||||
|
||||
Reference in new issue
Block a user