From c9ddb708b13a20d22aadec09176a5ea7ef51e16a Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Mon, 9 Mar 2026 23:37:58 +0200 Subject: [PATCH] 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 --- backend/internal/user/calories.go | 22 +++++++- backend/internal/user/calories_test.go | 38 +++++++++++++ backend/internal/user/model.go | 6 +-- backend/internal/user/repository.go | 21 +++++--- .../user/repository_integration_test.go | 14 ++--- backend/internal/user/service.go | 22 +++++--- backend/internal/user/service_test.go | 35 ++++++++---- .../011_replace_age_with_date_of_birth.sql | 13 +++++ .../lib/features/profile/profile_screen.dart | 54 ++++++++++++------- .../lib/features/profile/profile_service.dart | 6 +-- client/lib/shared/models/user.dart | 20 +++++-- client/lib/shared/models/user.g.dart | 4 +- client/pubspec.lock | 20 +++---- 13 files changed, 202 insertions(+), 73 deletions(-) create mode 100644 backend/migrations/011_replace_age_with_date_of_birth.sql diff --git a/backend/internal/user/calories.go b/backend/internal/user/calories.go index 5ba6d6a..b67c51d 100644 --- a/backend/internal/user/calories.go +++ b/backend/internal/user/calories.go @@ -1,6 +1,9 @@ package user -import "math" +import ( + "math" + "time" +) // Activity level multipliers (Mifflin-St Jeor). var activityMultiplier = map[string]float64{ @@ -16,6 +19,23 @@ var goalAdjustment = map[string]float64{ "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 // 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 { diff --git a/backend/internal/user/calories_test.go b/backend/internal/user/calories_test.go index 952b5ff..c1e131d 100644 --- a/backend/internal/user/calories_test.go +++ b/backend/internal/user/calories_test.go @@ -2,10 +2,48 @@ package user import ( "testing" + "time" ) 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) { cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain")) if cal == nil { diff --git a/backend/internal/user/model.go b/backend/internal/user/model.go index cf23c4f..4f54124 100644 --- a/backend/internal/user/model.go +++ b/backend/internal/user/model.go @@ -13,7 +13,7 @@ type User struct { AvatarURL *string `json:"avatar_url"` HeightCM *int `json:"height_cm"` WeightKG *float64 `json:"weight_kg"` - Age *int `json:"age"` + DateOfBirth *string `json:"date_of_birth"` Gender *string `json:"gender"` Activity *string `json:"activity"` Goal *string `json:"goal"` @@ -28,7 +28,7 @@ type UpdateProfileRequest struct { Name *string `json:"name"` HeightCM *int `json:"height_cm"` WeightKG *float64 `json:"weight_kg"` - Age *int `json:"age"` + DateOfBirth *string `json:"date_of_birth"` Gender *string `json:"gender"` Activity *string `json:"activity"` Goal *string `json:"goal"` @@ -39,6 +39,6 @@ type UpdateProfileRequest struct { // HasBodyParams returns true if any body parameter is being updated // that would require recalculation of daily calories. 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 } diff --git a/backend/internal/user/repository.go b/backend/internal/user/repository.go index 3689817..4108be5 100644 --- a/backend/internal/user/repository.go +++ b/backend/internal/user/repository.go @@ -45,7 +45,7 @@ func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), updated_at = now() 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` 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) { query := ` 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 FROM users WHERE id = $1` @@ -91,9 +91,9 @@ func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{ args = append(args, *req.WeightKG) argIdx++ } - if req.Age != nil { - setClauses = append(setClauses, fmt.Sprintf("age = $%d", argIdx)) - args = append(args, *req.Age) + if req.DateOfBirth != nil { + setClauses = append(setClauses, fmt.Sprintf("date_of_birth = $%d", argIdx)) + args = append(args, *req.DateOfBirth) argIdx++ } if req.Gender != nil { @@ -132,7 +132,7 @@ func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{ UPDATE users SET %s WHERE id = $%d 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`, strings.Join(setClauses, ", "), argIdx) 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) { query := ` 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 FROM users WHERE refresh_token = $1 AND token_expires_at > now()` @@ -205,14 +205,19 @@ type scannable interface { func (r *Repository) scanUser(row scannable) (*User, error) { var u User var prefs []byte + var dob *time.Time err := row.Scan( &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, ) if err != nil { 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) return &u, nil } diff --git a/backend/internal/user/repository_integration_test.go b/backend/internal/user/repository_integration_test.go index 209170c..2c04c45 100644 --- a/backend/internal/user/repository_integration_test.go +++ b/backend/internal/user/repository_integration_test.go @@ -100,17 +100,17 @@ func TestRepository_Update_MultipleFields(t *testing.T) { created, _ := repo.UpsertByFirebaseUID(ctx, "fb-multi", "multi@example.com", "Multi User", "") height := 175 weight := 70.5 - age := 25 + dob := "2001-03-09" gender := "male" activity := "moderate" goal := "maintain" u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{ - HeightCM: &height, - WeightKG: &weight, - Age: &age, - Gender: &gender, - Activity: &activity, - Goal: &goal, + HeightCM: &height, + WeightKG: &weight, + DateOfBirth: &dob, + Gender: &gender, + Activity: &activity, + Goal: &goal, }) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go index d52c30e..b81580f 100644 --- a/backend/internal/user/service.go +++ b/backend/internal/user/service.go @@ -3,6 +3,7 @@ package user import ( "context" "fmt" + "time" ) type Service struct { @@ -46,10 +47,11 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdatePr if req.WeightKG != nil { weight = req.WeightKG } - age := current.Age - if req.Age != nil { - age = req.Age + dob := current.DateOfBirth + if req.DateOfBirth != nil { + dob = req.DateOfBirth } + age := AgeFromDOB(dob) gender := current.Gender if req.Gender != nil { gender = req.Gender @@ -89,9 +91,17 @@ func validateProfileRequest(req UpdateProfileRequest) error { return fmt.Errorf("weight_kg must be between 30 and 300") } } - if req.Age != nil { - if *req.Age < 10 || *req.Age > 120 { - return fmt.Errorf("age must be between 10 and 120") + if req.DateOfBirth != nil { + t, err := time.Parse("2006-01-02", *req.DateOfBirth) + 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 { diff --git a/backend/internal/user/service_test.go b/backend/internal/user/service_test.go index 8e76e6a..4c9bdf5 100644 --- a/backend/internal/user/service_test.go +++ b/backend/internal/user/service_test.go @@ -94,19 +94,20 @@ func TestUpdateProfile_NameOnly(t *testing.T) { } func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) { + dob := time.Now().AddDate(-30, 0, 0).Format("2006-01-02") profileReq := UpdateProfileRequest{ - HeightCM: ptrInt(180), - WeightKG: ptrFloat(80), - Age: ptrInt(30), - Gender: ptrStr("male"), - Activity: ptrStr("moderate"), - Goal: ptrStr("maintain"), + HeightCM: ptrInt(180), + WeightKG: ptrFloat(80), + DateOfBirth: &dob, + Gender: ptrStr("male"), + Activity: ptrStr("moderate"), + Goal: ptrStr("maintain"), } finalUser := &User{ ID: "user-1", HeightCM: ptrInt(180), WeightKG: ptrFloat(80), - Age: ptrInt(30), + DateOfBirth: &dob, Gender: ptrStr("male"), Activity: ptrStr("moderate"), 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{}) + 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 { t.Fatal("expected validation error") } } -func TestUpdateProfile_InvalidAge_TooHigh(t *testing.T) { +func TestUpdateProfile_InvalidDOB_TooOld(t *testing.T) { 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 { t.Fatal("expected validation error") } diff --git a/backend/migrations/011_replace_age_with_date_of_birth.sql b/backend/migrations/011_replace_age_with_date_of_birth.sql new file mode 100644 index 0000000..58c80e5 --- /dev/null +++ b/backend/migrations/011_replace_age_with_date_of_birth.sql @@ -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; diff --git a/client/lib/features/profile/profile_screen.dart b/client/lib/features/profile/profile_screen.dart index c6f5e91..1a62ffe 100644 --- a/client/lib/features/profile/profile_screen.dart +++ b/client/lib/features/profile/profile_screen.dart @@ -336,7 +336,7 @@ class _EditProfileSheetState extends ConsumerState { 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 { _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 { _nameCtrl.dispose(); _heightCtrl.dispose(); _weightCtrl.dispose(); - _ageCtrl.dispose(); super.dispose(); } String _fmt(double w) => w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1); + Future _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 _save() async { if (!_formKey.currentState!.validate()) return; setState(() => _saving = true); @@ -378,7 +390,11 @@ class _EditProfileSheetState extends ConsumerState { 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 { ), 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), diff --git a/client/lib/features/profile/profile_service.dart b/client/lib/features/profile/profile_service.dart index 9daacb2..6514148 100644 --- a/client/lib/features/profile/profile_service.dart +++ b/client/lib/features/profile/profile_service.dart @@ -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; diff --git a/client/lib/shared/models/user.dart b/client/lib/shared/models/user.dart index 6e260f1..47a3583 100644 --- a/client/lib/shared/models/user.dart +++ b/client/lib/shared/models/user.dart @@ -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 json) => _$UserFromJson(json); Map 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; } diff --git a/client/lib/shared/models/user.g.dart b/client/lib/shared/models/user.g.dart index d3309d7..449b830 100644 --- a/client/lib/shared/models/user.g.dart +++ b/client/lib/shared/models/user.g.dart @@ -13,7 +13,7 @@ User _$UserFromJson(Map 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 _$UserToJson(User instance) => { '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, diff --git a/client/pubspec.lock b/client/pubspec.lock index 79c5cdf..df7fdde 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -668,26 +668,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -993,10 +993,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.6" typed_data: dependency: transitive description: