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:
@@ -1,6 +1,9 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import "math"
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Activity level multipliers (Mifflin-St Jeor).
|
// Activity level multipliers (Mifflin-St Jeor).
|
||||||
var activityMultiplier = map[string]float64{
|
var activityMultiplier = map[string]float64{
|
||||||
@@ -16,6 +19,23 @@ var goalAdjustment = map[string]float64{
|
|||||||
"gain": 300,
|
"gain": 300,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgeFromDOB computes age in years from a "YYYY-MM-DD" string. Returns nil on error.
|
||||||
|
func AgeFromDOB(dob *string) *int {
|
||||||
|
if dob == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", *dob)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
years := now.Year() - t.Year()
|
||||||
|
if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) {
|
||||||
|
years--
|
||||||
|
}
|
||||||
|
return &years
|
||||||
|
}
|
||||||
|
|
||||||
// CalculateDailyCalories computes the daily calorie target using the
|
// CalculateDailyCalories computes the daily calorie target using the
|
||||||
// Mifflin-St Jeor equation. Returns nil if any required parameter is missing.
|
// Mifflin-St Jeor equation. Returns nil if any required parameter is missing.
|
||||||
func CalculateDailyCalories(heightCM *int, weightKG *float64, age *int, gender, activity, goal *string) *int {
|
func CalculateDailyCalories(heightCM *int, weightKG *float64, age *int, gender, activity, goal *string) *int {
|
||||||
|
|||||||
@@ -2,10 +2,48 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestAgeFromDOB_Nil(t *testing.T) {
|
||||||
|
if AgeFromDOB(nil) != nil {
|
||||||
|
t.Fatal("expected nil for nil input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgeFromDOB_InvalidFormat(t *testing.T) {
|
||||||
|
s := "not-a-date"
|
||||||
|
if AgeFromDOB(&s) != nil {
|
||||||
|
t.Fatal("expected nil for invalid format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgeFromDOB_ExactAge(t *testing.T) {
|
||||||
|
dob := time.Now().AddDate(-30, 0, 0).Format("2006-01-02")
|
||||||
|
age := AgeFromDOB(&dob)
|
||||||
|
if age == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if *age != 30 {
|
||||||
|
t.Errorf("expected 30, got %d", *age)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgeFromDOB_BeforeBirthday(t *testing.T) {
|
||||||
|
// Birthday is one day in the future relative to today-25y, so age should be 24
|
||||||
|
now := time.Now()
|
||||||
|
dob := time.Date(now.Year()-25, now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
|
||||||
|
age := AgeFromDOB(&dob)
|
||||||
|
if age == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if *age != 24 {
|
||||||
|
t.Errorf("expected 24, got %d", *age)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateDailyCalories_MaleMaintain(t *testing.T) {
|
func TestCalculateDailyCalories_MaleMaintain(t *testing.T) {
|
||||||
cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain"))
|
cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain"))
|
||||||
if cal == nil {
|
if cal == nil {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type User struct {
|
|||||||
AvatarURL *string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
HeightCM *int `json:"height_cm"`
|
HeightCM *int `json:"height_cm"`
|
||||||
WeightKG *float64 `json:"weight_kg"`
|
WeightKG *float64 `json:"weight_kg"`
|
||||||
Age *int `json:"age"`
|
DateOfBirth *string `json:"date_of_birth"`
|
||||||
Gender *string `json:"gender"`
|
Gender *string `json:"gender"`
|
||||||
Activity *string `json:"activity"`
|
Activity *string `json:"activity"`
|
||||||
Goal *string `json:"goal"`
|
Goal *string `json:"goal"`
|
||||||
@@ -28,7 +28,7 @@ type UpdateProfileRequest struct {
|
|||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
HeightCM *int `json:"height_cm"`
|
HeightCM *int `json:"height_cm"`
|
||||||
WeightKG *float64 `json:"weight_kg"`
|
WeightKG *float64 `json:"weight_kg"`
|
||||||
Age *int `json:"age"`
|
DateOfBirth *string `json:"date_of_birth"`
|
||||||
Gender *string `json:"gender"`
|
Gender *string `json:"gender"`
|
||||||
Activity *string `json:"activity"`
|
Activity *string `json:"activity"`
|
||||||
Goal *string `json:"goal"`
|
Goal *string `json:"goal"`
|
||||||
@@ -39,6 +39,6 @@ type UpdateProfileRequest struct {
|
|||||||
// HasBodyParams returns true if any body parameter is being updated
|
// HasBodyParams returns true if any body parameter is being updated
|
||||||
// that would require recalculation of daily calories.
|
// that would require recalculation of daily calories.
|
||||||
func (r *UpdateProfileRequest) HasBodyParams() bool {
|
func (r *UpdateProfileRequest) HasBodyParams() bool {
|
||||||
return r.HeightCM != nil || r.WeightKG != nil || r.Age != nil ||
|
return r.HeightCM != nil || r.WeightKG != nil || r.DateOfBirth != nil ||
|
||||||
r.Gender != nil || r.Activity != nil || r.Goal != nil
|
r.Gender != nil || r.Activity != nil || r.Goal != nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name,
|
|||||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||||
plan, preferences, created_at, updated_at`
|
plan, preferences, created_at, updated_at`
|
||||||
|
|
||||||
return r.scanUser(r.pool.QueryRow(ctx, query, uid, email, name, avatarPtr))
|
return r.scanUser(r.pool.QueryRow(ctx, query, uid, email, name, avatarPtr))
|
||||||
@@ -54,7 +54,7 @@ func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name,
|
|||||||
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
|
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, firebase_uid, email, name, avatar_url,
|
SELECT id, firebase_uid, email, name, avatar_url,
|
||||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||||
plan, preferences, created_at, updated_at
|
plan, preferences, created_at, updated_at
|
||||||
FROM users WHERE id = $1`
|
FROM users WHERE id = $1`
|
||||||
|
|
||||||
@@ -91,9 +91,9 @@ func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{
|
|||||||
args = append(args, *req.WeightKG)
|
args = append(args, *req.WeightKG)
|
||||||
argIdx++
|
argIdx++
|
||||||
}
|
}
|
||||||
if req.Age != nil {
|
if req.DateOfBirth != nil {
|
||||||
setClauses = append(setClauses, fmt.Sprintf("age = $%d", argIdx))
|
setClauses = append(setClauses, fmt.Sprintf("date_of_birth = $%d", argIdx))
|
||||||
args = append(args, *req.Age)
|
args = append(args, *req.DateOfBirth)
|
||||||
argIdx++
|
argIdx++
|
||||||
}
|
}
|
||||||
if req.Gender != nil {
|
if req.Gender != nil {
|
||||||
@@ -132,7 +132,7 @@ func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{
|
|||||||
UPDATE users SET %s
|
UPDATE users SET %s
|
||||||
WHERE id = $%d
|
WHERE id = $%d
|
||||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||||
plan, preferences, created_at, updated_at`,
|
plan, preferences, created_at, updated_at`,
|
||||||
strings.Join(setClauses, ", "), argIdx)
|
strings.Join(setClauses, ", "), argIdx)
|
||||||
args = append(args, id)
|
args = append(args, id)
|
||||||
@@ -184,7 +184,7 @@ func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expi
|
|||||||
func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) {
|
func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, firebase_uid, email, name, avatar_url,
|
SELECT id, firebase_uid, email, name, avatar_url,
|
||||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||||
plan, preferences, created_at, updated_at
|
plan, preferences, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE refresh_token = $1 AND token_expires_at > now()`
|
WHERE refresh_token = $1 AND token_expires_at > now()`
|
||||||
@@ -205,14 +205,19 @@ type scannable interface {
|
|||||||
func (r *Repository) scanUser(row scannable) (*User, error) {
|
func (r *Repository) scanUser(row scannable) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
var prefs []byte
|
var prefs []byte
|
||||||
|
var dob *time.Time
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&u.ID, &u.FirebaseUID, &u.Email, &u.Name, &u.AvatarURL,
|
&u.ID, &u.FirebaseUID, &u.Email, &u.Name, &u.AvatarURL,
|
||||||
&u.HeightCM, &u.WeightKG, &u.Age, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories,
|
&u.HeightCM, &u.WeightKG, &dob, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories,
|
||||||
&u.Plan, &prefs, &u.CreatedAt, &u.UpdatedAt,
|
&u.Plan, &prefs, &u.CreatedAt, &u.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scan user: %w", err)
|
return nil, fmt.Errorf("scan user: %w", err)
|
||||||
}
|
}
|
||||||
|
if dob != nil {
|
||||||
|
s := dob.Format("2006-01-02")
|
||||||
|
u.DateOfBirth = &s
|
||||||
|
}
|
||||||
u.Preferences = json.RawMessage(prefs)
|
u.Preferences = json.RawMessage(prefs)
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,14 +100,14 @@ func TestRepository_Update_MultipleFields(t *testing.T) {
|
|||||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-multi", "multi@example.com", "Multi User", "")
|
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-multi", "multi@example.com", "Multi User", "")
|
||||||
height := 175
|
height := 175
|
||||||
weight := 70.5
|
weight := 70.5
|
||||||
age := 25
|
dob := "2001-03-09"
|
||||||
gender := "male"
|
gender := "male"
|
||||||
activity := "moderate"
|
activity := "moderate"
|
||||||
goal := "maintain"
|
goal := "maintain"
|
||||||
u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{
|
u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{
|
||||||
HeightCM: &height,
|
HeightCM: &height,
|
||||||
WeightKG: &weight,
|
WeightKG: &weight,
|
||||||
Age: &age,
|
DateOfBirth: &dob,
|
||||||
Gender: &gender,
|
Gender: &gender,
|
||||||
Activity: &activity,
|
Activity: &activity,
|
||||||
Goal: &goal,
|
Goal: &goal,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -46,10 +47,11 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdatePr
|
|||||||
if req.WeightKG != nil {
|
if req.WeightKG != nil {
|
||||||
weight = req.WeightKG
|
weight = req.WeightKG
|
||||||
}
|
}
|
||||||
age := current.Age
|
dob := current.DateOfBirth
|
||||||
if req.Age != nil {
|
if req.DateOfBirth != nil {
|
||||||
age = req.Age
|
dob = req.DateOfBirth
|
||||||
}
|
}
|
||||||
|
age := AgeFromDOB(dob)
|
||||||
gender := current.Gender
|
gender := current.Gender
|
||||||
if req.Gender != nil {
|
if req.Gender != nil {
|
||||||
gender = req.Gender
|
gender = req.Gender
|
||||||
@@ -89,9 +91,17 @@ func validateProfileRequest(req UpdateProfileRequest) error {
|
|||||||
return fmt.Errorf("weight_kg must be between 30 and 300")
|
return fmt.Errorf("weight_kg must be between 30 and 300")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Age != nil {
|
if req.DateOfBirth != nil {
|
||||||
if *req.Age < 10 || *req.Age > 120 {
|
t, err := time.Parse("2006-01-02", *req.DateOfBirth)
|
||||||
return fmt.Errorf("age must be between 10 and 120")
|
if err != nil {
|
||||||
|
return fmt.Errorf("date_of_birth must be in YYYY-MM-DD format")
|
||||||
|
}
|
||||||
|
if t.After(time.Now()) {
|
||||||
|
return fmt.Errorf("date_of_birth cannot be in the future")
|
||||||
|
}
|
||||||
|
age := AgeFromDOB(req.DateOfBirth)
|
||||||
|
if *age < 10 || *age > 120 {
|
||||||
|
return fmt.Errorf("date_of_birth must yield an age between 10 and 120")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Gender != nil {
|
if req.Gender != nil {
|
||||||
|
|||||||
@@ -94,10 +94,11 @@ func TestUpdateProfile_NameOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) {
|
func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) {
|
||||||
|
dob := time.Now().AddDate(-30, 0, 0).Format("2006-01-02")
|
||||||
profileReq := UpdateProfileRequest{
|
profileReq := UpdateProfileRequest{
|
||||||
HeightCM: ptrInt(180),
|
HeightCM: ptrInt(180),
|
||||||
WeightKG: ptrFloat(80),
|
WeightKG: ptrFloat(80),
|
||||||
Age: ptrInt(30),
|
DateOfBirth: &dob,
|
||||||
Gender: ptrStr("male"),
|
Gender: ptrStr("male"),
|
||||||
Activity: ptrStr("moderate"),
|
Activity: ptrStr("moderate"),
|
||||||
Goal: ptrStr("maintain"),
|
Goal: ptrStr("maintain"),
|
||||||
@@ -106,7 +107,7 @@ func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) {
|
|||||||
ID: "user-1",
|
ID: "user-1",
|
||||||
HeightCM: ptrInt(180),
|
HeightCM: ptrInt(180),
|
||||||
WeightKG: ptrFloat(80),
|
WeightKG: ptrFloat(80),
|
||||||
Age: ptrInt(30),
|
DateOfBirth: &dob,
|
||||||
Gender: ptrStr("male"),
|
Gender: ptrStr("male"),
|
||||||
Activity: ptrStr("moderate"),
|
Activity: ptrStr("moderate"),
|
||||||
Goal: ptrStr("maintain"),
|
Goal: ptrStr("maintain"),
|
||||||
@@ -178,19 +179,31 @@ func TestUpdateProfile_InvalidWeight_TooHigh(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateProfile_InvalidAge_TooLow(t *testing.T) {
|
func TestUpdateProfile_InvalidDOB_TooRecent(t *testing.T) {
|
||||||
svc := NewService(&mockUserRepo{})
|
svc := NewService(&mockUserRepo{})
|
||||||
|
dob := time.Now().AddDate(-5, 0, 0).Format("2006-01-02")
|
||||||
|
|
||||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(5)})
|
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{DateOfBirth: &dob})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected validation error")
|
t.Fatal("expected validation error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateProfile_InvalidAge_TooHigh(t *testing.T) {
|
func TestUpdateProfile_InvalidDOB_TooOld(t *testing.T) {
|
||||||
svc := NewService(&mockUserRepo{})
|
svc := NewService(&mockUserRepo{})
|
||||||
|
dob := time.Now().AddDate(-150, 0, 0).Format("2006-01-02")
|
||||||
|
|
||||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(150)})
|
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{DateOfBirth: &dob})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProfile_InvalidDOB_BadFormat(t *testing.T) {
|
||||||
|
svc := NewService(&mockUserRepo{})
|
||||||
|
dob := "not-a-date"
|
||||||
|
|
||||||
|
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{DateOfBirth: &dob})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected validation error")
|
t.Fatal("expected validation error")
|
||||||
}
|
}
|
||||||
|
|||||||
13
backend/migrations/011_replace_age_with_date_of_birth.sql
Normal file
13
backend/migrations/011_replace_age_with_date_of_birth.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE users ADD COLUMN date_of_birth DATE;
|
||||||
|
UPDATE users
|
||||||
|
SET date_of_birth = (CURRENT_DATE - (age * INTERVAL '1 year'))::DATE
|
||||||
|
WHERE age IS NOT NULL;
|
||||||
|
ALTER TABLE users DROP COLUMN age;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE users ADD COLUMN age SMALLINT;
|
||||||
|
UPDATE users
|
||||||
|
SET age = EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth))::SMALLINT
|
||||||
|
WHERE date_of_birth IS NOT NULL;
|
||||||
|
ALTER TABLE users DROP COLUMN date_of_birth;
|
||||||
@@ -336,7 +336,7 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
|
|||||||
late final TextEditingController _nameCtrl;
|
late final TextEditingController _nameCtrl;
|
||||||
late final TextEditingController _heightCtrl;
|
late final TextEditingController _heightCtrl;
|
||||||
late final TextEditingController _weightCtrl;
|
late final TextEditingController _weightCtrl;
|
||||||
late final TextEditingController _ageCtrl;
|
DateTime? _selectedDob;
|
||||||
String? _gender;
|
String? _gender;
|
||||||
String? _goal;
|
String? _goal;
|
||||||
String? _activity;
|
String? _activity;
|
||||||
@@ -351,7 +351,8 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
|
|||||||
_heightCtrl = TextEditingController(text: u.heightCm?.toString() ?? '');
|
_heightCtrl = TextEditingController(text: u.heightCm?.toString() ?? '');
|
||||||
_weightCtrl = TextEditingController(
|
_weightCtrl = TextEditingController(
|
||||||
text: u.weightKg != null ? _fmt(u.weightKg!) : '');
|
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;
|
_gender = u.gender;
|
||||||
_goal = u.goal;
|
_goal = u.goal;
|
||||||
_activity = u.activity;
|
_activity = u.activity;
|
||||||
@@ -363,13 +364,24 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
|
|||||||
_nameCtrl.dispose();
|
_nameCtrl.dispose();
|
||||||
_heightCtrl.dispose();
|
_heightCtrl.dispose();
|
||||||
_weightCtrl.dispose();
|
_weightCtrl.dispose();
|
||||||
_ageCtrl.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmt(double w) =>
|
String _fmt(double w) =>
|
||||||
w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1);
|
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 {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
@@ -378,7 +390,11 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
|
|||||||
name: _nameCtrl.text.trim(),
|
name: _nameCtrl.text.trim(),
|
||||||
heightCm: int.tryParse(_heightCtrl.text),
|
heightCm: int.tryParse(_heightCtrl.text),
|
||||||
weightKg: double.tryParse(_weightCtrl.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,
|
gender: _gender,
|
||||||
goal: _goal,
|
goal: _goal,
|
||||||
activity: _activity,
|
activity: _activity,
|
||||||
@@ -506,21 +522,21 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Age
|
// Date of birth
|
||||||
TextFormField(
|
InkWell(
|
||||||
controller: _ageCtrl,
|
onTap: _pickDob,
|
||||||
|
child: InputDecorator(
|
||||||
decoration:
|
decoration:
|
||||||
const InputDecoration(labelText: 'Возраст (лет)'),
|
const InputDecoration(labelText: 'Дата рождения'),
|
||||||
keyboardType: TextInputType.number,
|
child: Text(
|
||||||
inputFormatters: [
|
_selectedDob != null
|
||||||
FilteringTextInputFormatter.digitsOnly
|
? '${_selectedDob!.day.toString().padLeft(2, '0')}.'
|
||||||
],
|
'${_selectedDob!.month.toString().padLeft(2, '0')}.'
|
||||||
validator: (v) {
|
'${_selectedDob!.year}'
|
||||||
if (v == null || v.isEmpty) return null;
|
: 'Не задано',
|
||||||
final n = int.tryParse(v);
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
if (n == null || n < 10 || n > 120) return '10–120';
|
),
|
||||||
return null;
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class UpdateProfileRequest {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final int? heightCm;
|
final int? heightCm;
|
||||||
final double? weightKg;
|
final double? weightKg;
|
||||||
final int? age;
|
final String? dateOfBirth;
|
||||||
final String? gender;
|
final String? gender;
|
||||||
final String? activity;
|
final String? activity;
|
||||||
final String? goal;
|
final String? goal;
|
||||||
@@ -22,7 +22,7 @@ class UpdateProfileRequest {
|
|||||||
this.name,
|
this.name,
|
||||||
this.heightCm,
|
this.heightCm,
|
||||||
this.weightKg,
|
this.weightKg,
|
||||||
this.age,
|
this.dateOfBirth,
|
||||||
this.gender,
|
this.gender,
|
||||||
this.activity,
|
this.activity,
|
||||||
this.goal,
|
this.goal,
|
||||||
@@ -34,7 +34,7 @@ class UpdateProfileRequest {
|
|||||||
if (name != null) map['name'] = name;
|
if (name != null) map['name'] = name;
|
||||||
if (heightCm != null) map['height_cm'] = heightCm;
|
if (heightCm != null) map['height_cm'] = heightCm;
|
||||||
if (weightKg != null) map['weight_kg'] = weightKg;
|
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 (gender != null) map['gender'] = gender;
|
||||||
if (activity != null) map['activity'] = activity;
|
if (activity != null) map['activity'] = activity;
|
||||||
if (goal != null) map['goal'] = goal;
|
if (goal != null) map['goal'] = goal;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class User {
|
|||||||
final int? heightCm;
|
final int? heightCm;
|
||||||
@JsonKey(name: 'weight_kg')
|
@JsonKey(name: 'weight_kg')
|
||||||
final double? weightKg;
|
final double? weightKg;
|
||||||
final int? age;
|
@JsonKey(name: 'date_of_birth')
|
||||||
|
final String? dateOfBirth;
|
||||||
final String? gender;
|
final String? gender;
|
||||||
final String? activity;
|
final String? activity;
|
||||||
final String? goal;
|
final String? goal;
|
||||||
@@ -30,7 +31,7 @@ class User {
|
|||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.heightCm,
|
this.heightCm,
|
||||||
this.weightKg,
|
this.weightKg,
|
||||||
this.age,
|
this.dateOfBirth,
|
||||||
this.gender,
|
this.gender,
|
||||||
this.activity,
|
this.activity,
|
||||||
this.goal,
|
this.goal,
|
||||||
@@ -42,6 +43,19 @@ class User {
|
|||||||
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$UserToJson(this);
|
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 =>
|
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?,
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
heightCm: (json['height_cm'] as num?)?.toInt(),
|
heightCm: (json['height_cm'] as num?)?.toInt(),
|
||||||
weightKg: (json['weight_kg'] as num?)?.toDouble(),
|
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?,
|
gender: json['gender'] as String?,
|
||||||
activity: json['activity'] as String?,
|
activity: json['activity'] as String?,
|
||||||
goal: json['goal'] as String?,
|
goal: json['goal'] as String?,
|
||||||
@@ -29,7 +29,7 @@ Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
|
|||||||
'avatar_url': instance.avatarUrl,
|
'avatar_url': instance.avatarUrl,
|
||||||
'height_cm': instance.heightCm,
|
'height_cm': instance.heightCm,
|
||||||
'weight_kg': instance.weightKg,
|
'weight_kg': instance.weightKg,
|
||||||
'age': instance.age,
|
'date_of_birth': instance.dateOfBirth,
|
||||||
'gender': instance.gender,
|
'gender': instance.gender,
|
||||||
'activity': instance.activity,
|
'activity': instance.activity,
|
||||||
'goal': instance.goal,
|
'goal': instance.goal,
|
||||||
|
|||||||
@@ -125,10 +125,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -668,26 +668,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.16.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -993,10 +993,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.6"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user