feat: replace age integer with date_of_birth across backend and client

Store date_of_birth (DATE) instead of a static age integer so that age
is always computed dynamically from the stored date of birth.

- Migration 011: adds date_of_birth, backfills from age, drops age
- AgeFromDOB helper computes current age from YYYY-MM-DD string
- User model, repository SQL, and service validation updated
- Flutter: User.age becomes a computed getter; profile edit screen
  uses a date picker bounded to [today-120y, today-10y]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-09 23:37:58 +02:00
parent 765346d4e4
commit c9ddb708b1
13 changed files with 202 additions and 73 deletions

View File

@@ -336,7 +336,7 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
late final TextEditingController _nameCtrl;
late final TextEditingController _heightCtrl;
late final TextEditingController _weightCtrl;
late final TextEditingController _ageCtrl;
DateTime? _selectedDob;
String? _gender;
String? _goal;
String? _activity;
@@ -351,7 +351,8 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
_heightCtrl = TextEditingController(text: u.heightCm?.toString() ?? '');
_weightCtrl = TextEditingController(
text: u.weightKg != null ? _fmt(u.weightKg!) : '');
_ageCtrl = TextEditingController(text: u.age?.toString() ?? '');
_selectedDob =
u.dateOfBirth != null ? DateTime.tryParse(u.dateOfBirth!) : null;
_gender = u.gender;
_goal = u.goal;
_activity = u.activity;
@@ -363,13 +364,24 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
_nameCtrl.dispose();
_heightCtrl.dispose();
_weightCtrl.dispose();
_ageCtrl.dispose();
super.dispose();
}
String _fmt(double w) =>
w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1);
Future<void> _pickDob() async {
final now = DateTime.now();
final initial = _selectedDob ?? DateTime(now.year - 25, now.month, now.day);
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(now.year - 120),
lastDate: DateTime(now.year - 10, now.month, now.day),
);
if (picked != null) setState(() => _selectedDob = picked);
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
@@ -378,7 +390,11 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
name: _nameCtrl.text.trim(),
heightCm: int.tryParse(_heightCtrl.text),
weightKg: double.tryParse(_weightCtrl.text),
age: int.tryParse(_ageCtrl.text),
dateOfBirth: _selectedDob != null
? '${_selectedDob!.year.toString().padLeft(4, '0')}-'
'${_selectedDob!.month.toString().padLeft(2, '0')}-'
'${_selectedDob!.day.toString().padLeft(2, '0')}'
: null,
gender: _gender,
goal: _goal,
activity: _activity,
@@ -506,21 +522,21 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
),
const SizedBox(height: 12),
// Age
TextFormField(
controller: _ageCtrl,
decoration:
const InputDecoration(labelText: 'Возраст (лет)'),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
validator: (v) {
if (v == null || v.isEmpty) return null;
final n = int.tryParse(v);
if (n == null || n < 10 || n > 120) return '10120';
return null;
},
// Date of birth
InkWell(
onTap: _pickDob,
child: InputDecorator(
decoration:
const InputDecoration(labelText: 'Дата рождения'),
child: Text(
_selectedDob != null
? '${_selectedDob!.day.toString().padLeft(2, '0')}.'
'${_selectedDob!.month.toString().padLeft(2, '0')}.'
'${_selectedDob!.year}'
: 'Не задано',
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
const SizedBox(height: 20),

View File

@@ -12,7 +12,7 @@ class UpdateProfileRequest {
final String? name;
final int? heightCm;
final double? weightKg;
final int? age;
final String? dateOfBirth;
final String? gender;
final String? activity;
final String? goal;
@@ -22,7 +22,7 @@ class UpdateProfileRequest {
this.name,
this.heightCm,
this.weightKg,
this.age,
this.dateOfBirth,
this.gender,
this.activity,
this.goal,
@@ -34,7 +34,7 @@ class UpdateProfileRequest {
if (name != null) map['name'] = name;
if (heightCm != null) map['height_cm'] = heightCm;
if (weightKg != null) map['weight_kg'] = weightKg;
if (age != null) map['age'] = age;
if (dateOfBirth != null) map['date_of_birth'] = dateOfBirth;
if (gender != null) map['gender'] = gender;
if (activity != null) map['activity'] = activity;
if (goal != null) map['goal'] = goal;