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:
@@ -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 '10–120';
|
||||
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),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,7 +13,8 @@ class User {
|
||||
final int? heightCm;
|
||||
@JsonKey(name: 'weight_kg')
|
||||
final double? weightKg;
|
||||
final int? age;
|
||||
@JsonKey(name: 'date_of_birth')
|
||||
final String? dateOfBirth;
|
||||
final String? gender;
|
||||
final String? activity;
|
||||
final String? goal;
|
||||
@@ -30,7 +31,7 @@ class User {
|
||||
this.avatarUrl,
|
||||
this.heightCm,
|
||||
this.weightKg,
|
||||
this.age,
|
||||
this.dateOfBirth,
|
||||
this.gender,
|
||||
this.activity,
|
||||
this.goal,
|
||||
@@ -42,6 +43,19 @@ class User {
|
||||
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$UserToJson(this);
|
||||
|
||||
int? get age {
|
||||
if (dateOfBirth == null) return null;
|
||||
final dob = DateTime.tryParse(dateOfBirth!);
|
||||
if (dob == null) return null;
|
||||
final now = DateTime.now();
|
||||
int years = now.year - dob.year;
|
||||
if (now.month < dob.month ||
|
||||
(now.month == dob.month && now.day < dob.day)) {
|
||||
years--;
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
bool get hasCompletedOnboarding =>
|
||||
heightCm != null && weightKg != null && age != null && gender != null;
|
||||
heightCm != null && weightKg != null && dateOfBirth != null && gender != null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ User _$UserFromJson(Map<String, dynamic> json) => User(
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
heightCm: (json['height_cm'] as num?)?.toInt(),
|
||||
weightKg: (json['weight_kg'] as num?)?.toDouble(),
|
||||
age: (json['age'] as num?)?.toInt(),
|
||||
dateOfBirth: json['date_of_birth'] as String?,
|
||||
gender: json['gender'] as String?,
|
||||
activity: json['activity'] as String?,
|
||||
goal: json['goal'] as String?,
|
||||
@@ -29,7 +29,7 @@ Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
|
||||
'avatar_url': instance.avatarUrl,
|
||||
'height_cm': instance.heightCm,
|
||||
'weight_kg': instance.weightKg,
|
||||
'age': instance.age,
|
||||
'date_of_birth': instance.dateOfBirth,
|
||||
'gender': instance.gender,
|
||||
'activity': instance.activity,
|
||||
'goal': instance.goal,
|
||||
|
||||
Reference in New Issue
Block a user