Skip to content

Commit 4e8d562

Browse files
committed
feat(form): add process(), isProcessing, and processingListenable to MagicFormData
Adds form-scoped processing state management to MagicFormData: - process<T>() wraps async actions with automatic isProcessing toggling - processingListenable exposes a ValueListenable<bool> for MagicBuilder - Prevents concurrent submissions via StateError guard - Enables per-form loading indicators without full-page rebuilds
1 parent 2fa01f0 commit 4e8d562

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

lib/src/ui/magic_form_data.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/foundation.dart';
23

34
import '../concerns/validates_requests.dart';
45
import '../http/magic_controller.dart';
@@ -64,6 +65,9 @@ class MagicFormData {
6465
/// Internal map of value field names to their notifiers.
6566
final Map<String, ValueNotifier<dynamic>> _valueNotifiers = {};
6667

68+
/// Whether this form is currently being processed (submitted).
69+
final ValueNotifier<bool> _processing = ValueNotifier<bool>(false);
70+
6771
/// The controller that manages validation errors.
6872
final MagicController? controller;
6973

@@ -231,8 +235,61 @@ class MagicFormData {
231235
return errors.keys.any((key) => fieldNames.contains(key));
232236
}
233237

238+
/// Whether this form is currently being processed (submitted).
239+
///
240+
/// Returns `true` while [process] is executing, `false` otherwise.
241+
/// Use this for form-scoped loading indicators instead of the
242+
/// controller's global [MagicStateMixin.isLoading].
243+
bool get isProcessing => _processing.value;
244+
245+
/// Listenable for granular rebuilds of processing-dependent UI.
246+
///
247+
/// Use with [MagicBuilder] for efficient, form-scoped loading indicators
248+
/// that don't cause full-page rebuilds:
249+
///
250+
/// ```dart
251+
/// MagicBuilder<bool>(
252+
/// listenable: form.processingListenable,
253+
/// builder: (isProcessing) => WButton(
254+
/// isLoading: isProcessing,
255+
/// onTap: _submit,
256+
/// child: WText(trans('common.save')),
257+
/// ),
258+
/// )
259+
/// ```
260+
ValueListenable<bool> get processingListenable => _processing;
261+
262+
/// Execute an async action with automatic processing state management.
263+
///
264+
/// Sets [isProcessing] to `true` before execution and `false` after,
265+
/// regardless of success or failure. Prevents concurrent submissions
266+
/// by throwing [StateError] if already processing.
267+
///
268+
/// Returns the action's result on success. Rethrows any exception
269+
/// from [action] after resetting the processing state.
270+
///
271+
/// ```dart
272+
/// await form.process(() => controller.doUpdateProfile(
273+
/// name: form.get('name'),
274+
/// email: form.get('email'),
275+
/// ));
276+
/// ```
277+
Future<T> process<T>(Future<T> Function() action) async {
278+
if (_processing.value) {
279+
throw StateError('Form is already processing');
280+
}
281+
_processing.value = true;
282+
try {
283+
return await action();
284+
} finally {
285+
_processing.value = false;
286+
}
287+
}
288+
234289
/// Dispose all controllers and notifiers.
235290
void dispose() {
291+
_processing.dispose();
292+
236293
for (final controller in _textControllers.values) {
237294
controller.dispose();
238295
}

test/ui/magic_form_data_test.dart

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/foundation.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:magic/magic.dart';
6+
7+
/// Minimal controller for test isolation.
8+
class _TestController extends MagicController
9+
with MagicStateMixin<bool>, ValidatesRequests {
10+
_TestController() {
11+
onInit();
12+
}
13+
}
14+
15+
void main() {
16+
group('MagicFormData.isProcessing', () {
17+
late _TestController controller;
18+
late MagicFormData form;
19+
20+
setUp(() {
21+
controller = _TestController();
22+
form = MagicFormData(
23+
{
24+
'name': '',
25+
'email': '',
26+
},
27+
controller: controller,
28+
);
29+
});
30+
31+
tearDown(() {
32+
form.dispose();
33+
controller.dispose();
34+
});
35+
36+
test('defaults to false', () {
37+
expect(form.isProcessing, isFalse);
38+
});
39+
40+
test('processingListenable is a ValueListenable<bool>', () {
41+
expect(form.processingListenable, isA<ValueListenable<bool>>());
42+
expect(form.processingListenable.value, isFalse);
43+
});
44+
});
45+
46+
group('MagicFormData.process()', () {
47+
late _TestController controller;
48+
late MagicFormData form;
49+
50+
setUp(() {
51+
controller = _TestController();
52+
form = MagicFormData(
53+
{
54+
'name': '',
55+
'email': '',
56+
},
57+
controller: controller,
58+
);
59+
});
60+
61+
tearDown(() {
62+
form.dispose();
63+
controller.dispose();
64+
});
65+
66+
test('sets isProcessing to true during action', () async {
67+
bool? wasTrueDuringAction;
68+
69+
await form.process(() async {
70+
wasTrueDuringAction = form.isProcessing;
71+
return true;
72+
});
73+
74+
expect(wasTrueDuringAction, isTrue);
75+
expect(form.isProcessing, isFalse);
76+
});
77+
78+
test('returns the action result on success', () async {
79+
final result = await form.process(() async => 42);
80+
81+
expect(result, equals(42));
82+
});
83+
84+
test('resets isProcessing to false after success', () async {
85+
await form.process(() async => true);
86+
87+
expect(form.isProcessing, isFalse);
88+
});
89+
90+
test('resets isProcessing to false after exception', () async {
91+
try {
92+
await form.process(() async {
93+
throw Exception('API failure');
94+
});
95+
} catch (_) {
96+
// Expected.
97+
}
98+
99+
expect(form.isProcessing, isFalse);
100+
});
101+
102+
test('rethrows exception from action', () async {
103+
expect(
104+
() => form.process(() async {
105+
throw Exception('API failure');
106+
}),
107+
throwsA(isA<Exception>()),
108+
);
109+
});
110+
111+
test('throws StateError when already processing', () async {
112+
final completer = Completer<bool>();
113+
114+
// 1. Start a long-running process.
115+
final first = form.process(() => completer.future);
116+
117+
// 2. Attempt concurrent process — should throw.
118+
expect(
119+
() => form.process(() async => true),
120+
throwsA(isA<StateError>()),
121+
);
122+
123+
// 3. Complete the first process.
124+
completer.complete(true);
125+
await first;
126+
});
127+
128+
test('notifies processingListenable listeners', () async {
129+
final values = <bool>[];
130+
form.processingListenable.addListener(
131+
() => values.add(form.processingListenable.value),
132+
);
133+
134+
await form.process(() async => true);
135+
136+
// Should have notified: true (start), false (end).
137+
expect(values, equals([true, false]));
138+
});
139+
});
140+
141+
group('MagicFormData.process() with controller', () {
142+
late _TestController controller;
143+
late MagicFormData profileForm;
144+
late MagicFormData passwordForm;
145+
146+
setUp(() {
147+
controller = _TestController();
148+
profileForm = MagicFormData(
149+
{'name': '', 'email': ''},
150+
controller: controller,
151+
);
152+
passwordForm = MagicFormData(
153+
{'current_password': '', 'password': ''},
154+
controller: controller,
155+
);
156+
});
157+
158+
tearDown(() {
159+
profileForm.dispose();
160+
passwordForm.dispose();
161+
controller.dispose();
162+
});
163+
164+
test('one form processing does not affect another form', () async {
165+
final completer = Completer<bool>();
166+
167+
// 1. Start processing on profileForm.
168+
final future = profileForm.process(() => completer.future);
169+
170+
// 2. Verify only profileForm is processing.
171+
expect(profileForm.isProcessing, isTrue);
172+
expect(passwordForm.isProcessing, isFalse);
173+
174+
// 3. Complete.
175+
completer.complete(true);
176+
await future;
177+
178+
expect(profileForm.isProcessing, isFalse);
179+
});
180+
});
181+
182+
group('MagicFormData.dispose() with processing', () {
183+
late _TestController controller;
184+
late MagicFormData form;
185+
186+
setUp(() {
187+
controller = _TestController();
188+
form = MagicFormData(
189+
{'name': ''},
190+
controller: controller,
191+
);
192+
});
193+
194+
tearDown(() {
195+
controller.dispose();
196+
});
197+
198+
test('dispose cleans up processing notifier', () {
199+
// 1. Add a listener to prove the notifier is alive.
200+
// ignore: unused_local_variable
201+
var notified = false;
202+
form.processingListenable.addListener(() => notified = true);
203+
204+
// 2. Dispose.
205+
form.dispose();
206+
207+
// 3. Verify the notifier no longer fires (disposed).
208+
// FlutterError is thrown when accessing disposed notifier.
209+
expect(
210+
() => (form.processingListenable as ValueNotifier<bool>).value = true,
211+
throwsA(isA<FlutterError>()),
212+
);
213+
});
214+
});
215+
216+
group('MagicFormData backward compatibility', () {
217+
late _TestController controller;
218+
late MagicFormData form;
219+
220+
setUp(() {
221+
controller = _TestController();
222+
form = MagicFormData(
223+
{
224+
'name': 'John',
225+
'email': '',
226+
'accept_terms': false,
227+
},
228+
controller: controller,
229+
);
230+
});
231+
232+
tearDown(() {
233+
form.dispose();
234+
controller.dispose();
235+
});
236+
237+
test('text field operator[] still works', () {
238+
expect(form['name'].text, equals('John'));
239+
expect(form['email'].text, equals(''));
240+
});
241+
242+
test('get() and set() still work', () {
243+
form.set('name', 'Jane');
244+
expect(form.get('name'), equals('Jane'));
245+
});
246+
247+
test('value<T>() still works for non-text fields', () {
248+
expect(form.value<bool>('accept_terms'), isFalse);
249+
});
250+
251+
test('data getter still returns all fields', () {
252+
form.set('name', 'Jane');
253+
final data = form.data;
254+
255+
expect(data['name'], equals('Jane'));
256+
expect(data['email'], equals(''));
257+
expect(data['accept_terms'], equals(false));
258+
});
259+
260+
test('fieldNames includes all registered fields', () {
261+
expect(
262+
form.fieldNames,
263+
containsAll(['name', 'email', 'accept_terms']),
264+
);
265+
});
266+
});
267+
}

0 commit comments

Comments
 (0)