Skip to content

Commit af1f794

Browse files
core-eulerclaude
andcommitted
fix(client): replace age dropdown with a +/- + text field picker
Onboarding age was a DropdownButtonFormField with values 12-90. On Android two problems showed up in real testing: - Once a value was picked there was no way to clear it (the list has no empty/null option), so users couldn't undo the selection. - The dropdown behaved oddly with some Android input methods and occasionally captured unexpected keyboard input. Replaced with the same UX as the web onboarding: a row with a circular "−" button, a centered numeric TextField (digits-only filter, length-2, clamped 12-90), and a circular "+" button. Users can tap to type, backspace to clear, or use the buttons to step. Empty value is allowed (skipping age is fine and stays optional). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 514e390 commit af1f794

1 file changed

Lines changed: 137 additions & 24 deletions

File tree

client/lib/screens/onboarding_screen.dart

Lines changed: 137 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:ui';
22

33
import 'package:flutter/material.dart';
4+
import 'package:flutter/services.dart';
45
import 'package:provider/provider.dart';
56

67
import '../l10n/app_localizations.dart';
@@ -18,12 +19,12 @@ class OnboardingScreen extends StatefulWidget {
1819

1920
class _OnboardingScreenState extends State<OnboardingScreen> {
2021
static const _stepCount = 5;
21-
static final _ages = [for (var age = 12; age <= 90; age++) '$age'];
2222

2323
final _occupationController = TextEditingController();
2424
final _familyController = TextEditingController();
2525
final _interestsController = TextEditingController();
2626
final _lifeContextController = TextEditingController();
27+
final _ageController = TextEditingController();
2728

2829
int _step = 0;
2930
bool _loading = false;
@@ -36,6 +37,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
3637
_familyController.dispose();
3738
_interestsController.dispose();
3839
_lifeContextController.dispose();
40+
_ageController.dispose();
3941
super.dispose();
4042
}
4143

@@ -263,31 +265,18 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
263265
children: genders.map(genderButton).toList(),
264266
),
265267
const SizedBox(height: 18),
266-
DropdownButtonFormField<String>(
267-
initialValue: _age,
268-
items: _ages
269-
.map(
270-
(age) => DropdownMenuItem<String>(value: age, child: Text(age)),
271-
)
272-
.toList(),
273-
onChanged: _loading
274-
? null
275-
: (value) {
276-
setState(() {
277-
_age = value;
278-
});
279-
},
280-
decoration: InputDecoration(
281-
labelText: AppLocalizations.of(context)!.ageLabel,
282-
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
283-
enabledBorder: OutlineInputBorder(
284-
borderRadius: BorderRadius.circular(16),
285-
),
286-
focusedBorder: OutlineInputBorder(
287-
borderRadius: BorderRadius.circular(16),
288-
),
268+
Text(
269+
AppLocalizations.of(context)!.ageLabel,
270+
style: theme.textTheme.bodyMedium?.copyWith(
271+
color: theme.colorScheme.onSurface.withOpacity(0.7),
289272
),
290273
),
274+
const SizedBox(height: 10),
275+
_AgePicker(
276+
controller: _ageController,
277+
enabled: !_loading,
278+
onChanged: (value) => setState(() => _age = value),
279+
),
291280
],
292281
);
293282
}
@@ -338,6 +327,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
338327
if (_step == 0) {
339328
_gender = null;
340329
_age = null;
330+
_ageController.clear();
341331
} else if (_step == 1) {
342332
_occupationController.clear();
343333
} else if (_step == 2) {
@@ -415,3 +405,126 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
415405
}
416406
}
417407
}
408+
409+
class _AgePicker extends StatelessWidget {
410+
final TextEditingController controller;
411+
final bool enabled;
412+
final ValueChanged<String?> onChanged;
413+
414+
const _AgePicker({
415+
required this.controller,
416+
required this.enabled,
417+
required this.onChanged,
418+
});
419+
420+
static const int _min = 12;
421+
static const int _max = 90;
422+
static const int _default = 25;
423+
424+
int? _parsed() {
425+
final raw = controller.text.trim();
426+
if (raw.isEmpty) return null;
427+
return int.tryParse(raw);
428+
}
429+
430+
void _bump(int delta) {
431+
final current = _parsed() ?? _default;
432+
final next = (current + delta).clamp(_min, _max);
433+
controller.text = next.toString();
434+
controller.selection = TextSelection.collapsed(offset: controller.text.length);
435+
onChanged(controller.text);
436+
}
437+
438+
@override
439+
Widget build(BuildContext context) {
440+
final theme = Theme.of(context);
441+
final accent = theme.colorScheme.primary;
442+
return Container(
443+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
444+
decoration: BoxDecoration(
445+
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.4),
446+
borderRadius: BorderRadius.circular(24),
447+
),
448+
child: Row(
449+
children: [
450+
_RoundIconButton(
451+
icon: Icons.remove,
452+
onPressed: enabled ? () => _bump(-1) : null,
453+
accent: accent,
454+
),
455+
Expanded(
456+
child: TextField(
457+
controller: controller,
458+
enabled: enabled,
459+
keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false),
460+
inputFormatters: [
461+
FilteringTextInputFormatter.digitsOnly,
462+
LengthLimitingTextInputFormatter(2),
463+
],
464+
textAlign: TextAlign.center,
465+
style: theme.textTheme.displaySmall?.copyWith(
466+
fontWeight: FontWeight.w600,
467+
fontFeatures: const [FontFeature.tabularFigures()],
468+
),
469+
decoration: const InputDecoration(
470+
hintText: '—',
471+
border: InputBorder.none,
472+
isDense: true,
473+
contentPadding: EdgeInsets.symmetric(vertical: 4),
474+
),
475+
onChanged: (raw) {
476+
if (raw.isEmpty) {
477+
onChanged(null);
478+
return;
479+
}
480+
final n = int.tryParse(raw);
481+
if (n == null) {
482+
onChanged(null);
483+
return;
484+
}
485+
final clamped = n.clamp(_min, _max);
486+
if (clamped != n) {
487+
final s = clamped.toString();
488+
controller.text = s;
489+
controller.selection = TextSelection.collapsed(offset: s.length);
490+
onChanged(s);
491+
} else {
492+
onChanged(raw);
493+
}
494+
},
495+
),
496+
),
497+
_RoundIconButton(
498+
icon: Icons.add,
499+
onPressed: enabled ? () => _bump(1) : null,
500+
accent: accent,
501+
),
502+
],
503+
),
504+
);
505+
}
506+
}
507+
508+
class _RoundIconButton extends StatelessWidget {
509+
final IconData icon;
510+
final VoidCallback? onPressed;
511+
final Color accent;
512+
const _RoundIconButton({required this.icon, required this.onPressed, required this.accent});
513+
514+
@override
515+
Widget build(BuildContext context) {
516+
return Material(
517+
color: accent.withOpacity(0.12),
518+
shape: const CircleBorder(),
519+
child: InkWell(
520+
customBorder: const CircleBorder(),
521+
onTap: onPressed,
522+
child: SizedBox(
523+
width: 44,
524+
height: 44,
525+
child: Icon(icon, color: accent),
526+
),
527+
),
528+
);
529+
}
530+
}

0 commit comments

Comments
 (0)