User Management API
The User Management API provides comprehensive user profile management including personal information, fitness preferences, onboarding workflows, and profile image handling. This system manages both basic user data and detailed fitness-specific profile information.
⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY
What Flutter SHOULD do:
- ✅ Consume GraphQL endpoints for user data
- ✅ Display profile information and forms
- ✅ Handle UI state management for profile screens
- ✅ Cache user profile data for offline display
What Flutter MUST NOT do:
- ❌ Implement profile validation logic
- ❌ Calculate user fitness metrics client-side
- ❌ Duplicate onboarding business rules
- ❌ Make decisions about profile completeness
🏗️ User Profile Architecture
Core Profile Structure
The user profile system uses a two-tier architecture:
- Basic User Data - Core account information (email, username, bio, etc.)
- Detailed Profile - Fitness-specific embedded data (goals, measurements, preferences)
interface User {
// Basic account information
id: string;
email: string;
username: string;
firstName?: string;
lastName?: string;
bio?: string;
city?: string;
role: UserRole;
profileImageFileKey?: string;
profileImageUrl?: string;
// Fitness-specific profile (embedded)
profile: UserProfile;
// Onboarding status
onboardingStatus: OnboardingStatus;
}
interface UserProfile {
// Physical characteristics
age?: number;
gender?: Gender;
height?: number;
heightUnit?: HeightUnit;
weight?: number;
weightUnit?: WeightUnit;
dateOfBirth?: DateTime;
// Training preferences
trainingGoal?: TrainingGoal;
trainingLevel?: TrainingLevel;
trainingFrequency?: number;
preferredSplit?: WorkoutSplitPreference;
availableEquipment: Equipment[];
// App preferences
healthkitAuthorized?: boolean;
notificationsEnabled?: boolean;
// Onboarding tracking
onboardingCompletedAt?: DateTime;
onboardingVersion?: string;
}
🔍 GraphQL Schema
Core Types
- User Type
- UserProfile Type
- Enums & Supporting Types
type User {
id: ID!
email: String!
username: String!
firstName: String
lastName: String
bio: String
city: String
role: UserRole!
createdAt: DateTime!
# Profile image handling
profileImageFileKey: String
profileImageUrl: String
# Computed fields
fullName: String
# Embedded profile data
profile: UserProfile
# Onboarding status
onboardingStatus: OnboardingStatus!
}
type UserProfile {
# Physical characteristics
age: Int
gender: Gender
height: Float
heightUnit: HeightUnit
weight: Float
weightUnit: WeightUnit
dateOfBirth: DateTime
# Fitness goals and preferences
trainingGoal: TrainingGoal
trainingLevel: TrainingLevel
trainingFrequency: Int
preferredSplit: UserWorkoutSplitPreference
availableEquipment: [Equipment!]!
# App integration preferences
healthkitAuthorized: Boolean
notificationsEnabled: Boolean
# Onboarding tracking
onboardingCompletedAt: DateTime
onboardingVersion: String
# Metadata
createdAt: DateTime!
updatedAt: DateTime!
}
enum Gender {
MALE
FEMALE
NON_BINARY
PREFER_NOT_TO_SAY
}
enum HeightUnit {
CM
INCHES
}
enum WeightUnit {
KG
LBS
}
enum TrainingGoal {
MUSCLE_GAIN
STRENGTH_GAIN
WEIGHT_LOSS
ENDURANCE
GENERAL_FITNESS
}
enum TrainingLevel {
BEGINNER
INTERMEDIATE
ADVANCED
}
enum Equipment {
BARBELL
DUMBBELLS
KETTLEBELLS
RESISTANCE_BANDS
PULL_UP_BAR
BENCH
SQUAT_RACK
CABLE_MACHINE
BODYWEIGHT_ONLY
}
type UserWorkoutSplitPreference {
id: String!
name: String!
description: String
daysPerWeek: Int!
}
type OnboardingStatus {
isCompleted: Boolean!
completedAt: DateTime
shouldShowOnboarding: Boolean!
currentStep: Int!
version: String!
missingFields: [String!]!
}
🔧 GraphQL Operations
Queries
- Get Current User
- Get User By ID
query Me {
me {
id
email
username
firstName
lastName
bio
city
role
createdAt
fullName
profileImageFileKey
profileImageUrl
profile {
age
gender
height
heightUnit
weight
weightUnit
dateOfBirth
trainingGoal
trainingLevel
trainingFrequency
preferredSplit {
id
name
description
daysPerWeek
}
availableEquipment
healthkitAuthorized
notificationsEnabled
onboardingCompletedAt
onboardingVersion
createdAt
updatedAt
}
onboardingStatus {
isCompleted
completedAt
shouldShowOnboarding
currentStep
version
missingFields
}
}
}
Response Example:
{
"data": {
"me": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"email": "user@example.com",
"username": "fitnessuser",
"firstName": "John",
"lastName": "Doe",
"bio": "Passionate about fitness and strength training",
"city": "New York",
"role": "USER",
"createdAt": "2023-01-01T10:00:00.000Z",
"fullName": "John Doe",
"profileImageFileKey": "profile_images/user123.jpg",
"profileImageUrl": "https://storage.example.com/profile_images/user123.jpg?expires=1640999999",
"profile": {
"age": 28,
"gender": "MALE",
"height": 180,
"heightUnit": "CM",
"weight": 75.5,
"weightUnit": "KG",
"dateOfBirth": "1995-03-15T00:00:00.000Z",
"trainingGoal": "MUSCLE_GAIN",
"trainingLevel": "INTERMEDIATE",
"trainingFrequency": 4,
"preferredSplit": {
"id": "push_pull_legs",
"name": "Push/Pull/Legs",
"description": "6-day split focusing on push/pull movements and legs",
"daysPerWeek": 6
},
"availableEquipment": ["BARBELL", "DUMBBELLS", "BENCH", "SQUAT_RACK"],
"healthkitAuthorized": true,
"notificationsEnabled": true,
"onboardingCompletedAt": "2023-01-02T14:30:00.000Z",
"onboardingVersion": "v2.1",
"createdAt": "2023-01-01T10:00:00.000Z",
"updatedAt": "2023-12-01T09:15:00.000Z"
},
"onboardingStatus": {
"isCompleted": true,
"completedAt": "2023-01-02T14:30:00.000Z",
"shouldShowOnboarding": false,
"currentStep": 5,
"version": "v2.1",
"missingFields": []
}
}
}
}
Authorization: JWT token required Use Cases:
- App initialization and user context
- Profile screen display
- Navigation decisions based on onboarding status
query User($id: ID!) {
user(id: $id) {
id
username
firstName
lastName
bio
city
profileImageUrl
fullName
createdAt
# Note: Only public profile data exposed
}
}
Variables:
{
"id": "60d5ecb54f3d4d2e8c8e4b5a"
}
Response:
{
"data": {
"user": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"username": "fitnessuser",
"firstName": "John",
"lastName": "Doe",
"bio": "Passionate about fitness and strength training",
"city": "New York",
"profileImageUrl": "https://storage.example.com/profile_images/user123.jpg?expires=1640999999",
"fullName": "John Doe",
"createdAt": "2023-01-01T10:00:00.000Z"
}
}
}
Privacy: Returns only public profile information (email and detailed profile data excluded)
Profile Update Mutations
- Update Basic Profile
- Update Detailed Profile
- Change Password
mutation UpdateMyProfile($data: UpdateMyProfileInput!) {
updateMyProfile(data: $data) {
id
firstName
lastName
bio
fullName
updatedAt
}
}
Input Type:
input UpdateMyProfileInput {
firstName: String
lastName: String
bio: String
}
Variables Example:
{
"data": {
"firstName": "John",
"lastName": "Smith",
"bio": "Fitness enthusiast and certified personal trainer. Specializing in strength training and functional movement."
}
}
Response:
{
"data": {
"updateMyProfile": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"firstName": "John",
"lastName": "Smith",
"bio": "Fitness enthusiast and certified personal trainer. Specializing in strength training and functional movement.",
"fullName": "John Smith",
"updatedAt": "2023-12-01T10:30:00.000Z"
}
}
}
Business Rules:
- All fields are optional (partial updates supported)
- Bio has maximum length limit (enforced server-side)
fullNameis automatically computed from firstName + lastName
mutation UpdateMyUserProfileDetails($data: UpdateMyUserProfileDetailsInput!) {
updateMyUserProfileDetails(data: $data) {
id
profile {
age
gender
height
heightUnit
weight
weightUnit
trainingGoal
trainingLevel
trainingFrequency
updatedAt
}
}
}
Input Type:
input UpdateMyUserProfileDetailsInput {
age: Int
gender: Gender
height: Float
heightUnit: HeightUnit
weight: Float
weightUnit: WeightUnit
}
Variables Example:
{
"data": {
"age": 29,
"weight": 76.5,
"height": 181,
"trainingGoal": "STRENGTH_GAIN"
}
}
Response:
{
"data": {
"updateMyUserProfileDetails": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"profile": {
"age": 29,
"gender": "MALE",
"height": 181,
"heightUnit": "CM",
"weight": 76.5,
"weightUnit": "KG",
"trainingGoal": "STRENGTH_GAIN",
"trainingLevel": "INTERMEDIATE",
"trainingFrequency": 4,
"updatedAt": "2023-12-01T10:35:00.000Z"
}
}
}
}
Validations:
- Age: 13-120 years
- Weight/Height: Must be positive numbers
- Enum values validated server-side
mutation ChangePassword($input: ChangePasswordInput!) {
changePassword(input: $input)
}
Input Type:
input ChangePasswordInput {
currentPassword: String!
newPassword: String!
}
Variables Example:
{
"input": {
"currentPassword": "currentPassword123",
"newPassword": "newStrongPassword456!"
}
}
Response:
{
"data": {
"changePassword": true
}
}
Security Features:
- Current password verification required
- New password strength validation (server-side)
- Automatic logout of other sessions on password change
- Bcrypt hashing with configurable salt rounds
Error Handling:
INVALID_CURRENT_PASSWORD- Current password incorrectWEAK_PASSWORD- New password doesn't meet strength requirementsSAME_PASSWORD- New password same as current password
Onboarding Mutations
- Complete Onboarding
- Update Onboarding Data
mutation CompleteOnboarding($input: OnboardingDataInput!) {
completeOnboarding(input: $input) {
id
profile {
trainingGoal
trainingLevel
trainingFrequency
preferredSplit {
id
name
daysPerWeek
}
availableEquipment
age
gender
height
heightUnit
weight
weightUnit
dateOfBirth
healthkitAuthorized
notificationsEnabled
onboardingCompletedAt
onboardingVersion
}
onboardingStatus {
isCompleted
completedAt
shouldShowOnboarding
currentStep
version
missingFields
}
}
}
Input Type:
input OnboardingDataInput {
trainingGoal: TrainingGoal
trainingLevel: TrainingLevel
trainingFrequency: Int # 1-7 days per week
preferredSplit: WorkoutSplitInput
availableEquipment: [Equipment!]
dateOfBirth: DateTime
gender: Gender
weight: Float
weightUnit: WeightUnit
height: Float
heightUnit: HeightUnit
healthkitAuthorized: Boolean
notificationsEnabled: Boolean
}
input WorkoutSplitInput {
id: String!
name: String!
description: String
daysPerWeek: Int!
}
Variables Example:
{
"input": {
"trainingGoal": "MUSCLE_GAIN",
"trainingLevel": "BEGINNER",
"trainingFrequency": 3,
"preferredSplit": {
"id": "full_body",
"name": "Full Body",
"description": "Complete body workout 3x per week",
"daysPerWeek": 3
},
"availableEquipment": ["DUMBBELLS", "BODYWEIGHT_ONLY", "RESISTANCE_BANDS"],
"dateOfBirth": "1995-06-15T00:00:00.000Z",
"gender": "FEMALE",
"weight": 65,
"weightUnit": "KG",
"height": 165,
"heightUnit": "CM",
"healthkitAuthorized": true,
"notificationsEnabled": true
}
}
Response:
{
"data": {
"completeOnboarding": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"profile": {
"trainingGoal": "MUSCLE_GAIN",
"trainingLevel": "BEGINNER",
"trainingFrequency": 3,
"preferredSplit": {
"id": "full_body",
"name": "Full Body",
"daysPerWeek": 3
},
"availableEquipment": ["DUMBBELLS", "BODYWEIGHT_ONLY", "RESISTANCE_BANDS"],
"age": 28,
"gender": "FEMALE",
"height": 165,
"heightUnit": "CM",
"weight": 65,
"weightUnit": "KG",
"dateOfBirth": "1995-06-15T00:00:00.000Z",
"healthkitAuthorized": true,
"notificationsEnabled": true,
"onboardingCompletedAt": "2023-12-01T10:45:00.000Z",
"onboardingVersion": "v2.1"
},
"onboardingStatus": {
"isCompleted": true,
"completedAt": "2023-12-01T10:45:00.000Z",
"shouldShowOnboarding": false,
"currentStep": 5,
"version": "v2.1",
"missingFields": []
}
}
}
}
Business Logic:
- Marks onboarding as completed with timestamp
- Calculates age from dateOfBirth
- Validates all required fields are provided
- Sets current onboarding version
- Triggers program recommendation system (server-side)
mutation UpdateOnboardingData($input: UpdateOnboardingDataInput!) {
updateOnboardingData(input: $input) {
id
profile {
trainingGoal
trainingLevel
trainingFrequency
availableEquipment
onboardingCompletedAt
updatedAt
}
onboardingStatus {
isCompleted
shouldShowOnboarding
currentStep
missingFields
}
}
}
Input Type:
input UpdateOnboardingDataInput {
trainingGoal: TrainingGoal
trainingLevel: TrainingLevel
trainingFrequency: Int
preferredSplit: WorkoutSplitInput
availableEquipment: [Equipment!]
dateOfBirth: DateTime
gender: Gender
weight: Float
weightUnit: WeightUnit
height: Float
heightUnit: HeightUnit
healthkitAuthorized: Boolean
notificationsEnabled: Boolean
}
Variables Example:
{
"input": {
"trainingGoal": "STRENGTH_GAIN",
"availableEquipment": ["BARBELL", "DUMBBELLS", "BENCH", "SQUAT_RACK"]
}
}
Purpose:
- Step-by-step onboarding completion
- Updates profile data without marking onboarding as complete
- Tracks current step progress
- Updates missing fields list
Use Cases:
- Multi-step onboarding flows
- Allowing users to skip steps and return later
- Progressive profile completion
📱 Flutter Implementation
Complete User Management Setup
- Model Classes
- User Service
- Profile Screen UI
- Onboarding Flow
import 'package:json_annotation/json_annotation.dart';
part 'user_models.g.dart';
@JsonSerializable()
class User {
final String id;
final String email;
final String username;
final String? firstName;
final String? lastName;
final String? bio;
final String? city;
final UserRole role;
final DateTime createdAt;
final String? profileImageFileKey;
final String? profileImageUrl;
final String? fullName;
final UserProfile? profile;
final OnboardingStatus onboardingStatus;
User({
required this.id,
required this.email,
required this.username,
this.firstName,
this.lastName,
this.bio,
this.city,
required this.role,
required this.createdAt,
this.profileImageFileKey,
this.profileImageUrl,
this.fullName,
this.profile,
required this.onboardingStatus,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
// Helper methods
bool get hasCompletedProfile =>
firstName != null && lastName != null && bio != null;
bool get needsOnboarding => !onboardingStatus.isCompleted;
String get displayName => fullName ?? username;
}
@JsonSerializable()
class UserProfile {
final int? age;
final Gender? gender;
final double? height;
final HeightUnit? heightUnit;
final double? weight;
final WeightUnit? weightUnit;
final DateTime? dateOfBirth;
final TrainingGoal? trainingGoal;
final TrainingLevel? trainingLevel;
final int? trainingFrequency;
final WorkoutSplitPreference? preferredSplit;
final List<Equipment> availableEquipment;
final bool? healthkitAuthorized;
final bool? notificationsEnabled;
final DateTime? onboardingCompletedAt;
final String? onboardingVersion;
final DateTime createdAt;
final DateTime updatedAt;
UserProfile({
this.age,
this.gender,
this.height,
this.heightUnit,
this.weight,
this.weightUnit,
this.dateOfBirth,
this.trainingGoal,
this.trainingLevel,
this.trainingFrequency,
this.preferredSplit,
required this.availableEquipment,
this.healthkitAuthorized,
this.notificationsEnabled,
this.onboardingCompletedAt,
this.onboardingVersion,
required this.createdAt,
required this.updatedAt,
});
factory UserProfile.fromJson(Map<String, dynamic> json) =>
_$UserProfileFromJson(json);
Map<String, dynamic> toJson() => _$UserProfileToJson(this);
// Helper methods
bool get hasPhysicalData => weight != null && height != null && age != null;
bool get hasTrainingPrefs => trainingGoal != null && trainingLevel != null;
String get equipmentSummary => availableEquipment.map((e) => e.name).join(', ');
}
@JsonSerializable()
class OnboardingStatus {
final bool isCompleted;
final DateTime? completedAt;
final bool shouldShowOnboarding;
final int currentStep;
final String version;
final List<String> missingFields;
OnboardingStatus({
required this.isCompleted,
this.completedAt,
required this.shouldShowOnboarding,
required this.currentStep,
required this.version,
required this.missingFields,
});
factory OnboardingStatus.fromJson(Map<String, dynamic> json) =>
_$OnboardingStatusFromJson(json);
Map<String, dynamic> toJson() => _$OnboardingStatusToJson(this);
}
@JsonSerializable()
class WorkoutSplitPreference {
final String id;
final String name;
final String? description;
final int daysPerWeek;
WorkoutSplitPreference({
required this.id,
required this.name,
this.description,
required this.daysPerWeek,
});
factory WorkoutSplitPreference.fromJson(Map<String, dynamic> json) =>
_$WorkoutSplitPreferenceFromJson(json);
Map<String, dynamic> toJson() => _$WorkoutSplitPreferenceToJson(this);
}
// Enums
enum UserRole { USER, ADMIN }
enum Gender { MALE, FEMALE, NON_BINARY, PREFER_NOT_TO_SAY }
enum HeightUnit { CM, INCHES }
enum WeightUnit { KG, LBS }
enum TrainingGoal { MUSCLE_GAIN, STRENGTH_GAIN, WEIGHT_LOSS, ENDURANCE, GENERAL_FITNESS }
enum TrainingLevel { BEGINNER, INTERMEDIATE, ADVANCED }
enum Equipment {
BARBELL, DUMBBELLS, KETTLEBELLS, RESISTANCE_BANDS, PULL_UP_BAR,
BENCH, SQUAT_RACK, CABLE_MACHINE, BODYWEIGHT_ONLY
}
import 'package:graphql_flutter/graphql_flutter.dart';
import 'user_models.dart';
class UserService {
final GraphQLClient _client;
UserService(this._client);
// Get current user profile
Future<User> getCurrentUser() async {
const String query = '''
query Me {
me {
id
email
username
firstName
lastName
bio
city
role
createdAt
fullName
profileImageFileKey
profileImageUrl
profile {
age
gender
height
heightUnit
weight
weightUnit
dateOfBirth
trainingGoal
trainingLevel
trainingFrequency
preferredSplit {
id
name
description
daysPerWeek
}
availableEquipment
healthkitAuthorized
notificationsEnabled
onboardingCompletedAt
onboardingVersion
createdAt
updatedAt
}
onboardingStatus {
isCompleted
completedAt
shouldShowOnboarding
currentStep
version
missingFields
}
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.cacheAndNetwork,
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw UserServiceException.fromGraphQLError(result.exception!);
}
return User.fromJson(result.data!['me']);
}
// Update basic profile information
Future<User> updateBasicProfile(UpdateBasicProfileData data) async {
const String mutation = '''
mutation UpdateMyProfile(\$data: UpdateMyProfileInput!) {
updateMyProfile(data: \$data) {
id
firstName
lastName
bio
fullName
updatedAt
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'data': data.toJson()},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw UserServiceException.fromGraphQLError(result.exception!);
}
return User.fromJson(result.data!['updateMyProfile']);
}
// Update detailed profile information
Future<User> updateDetailedProfile(UpdateDetailedProfileData data) async {
const String mutation = '''
mutation UpdateMyUserProfileDetails(\$data: UpdateMyUserProfileDetailsInput!) {
updateMyUserProfileDetails(data: \$data) {
id
profile {
age
gender
height
heightUnit
weight
weightUnit
updatedAt
}
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'data': data.toJson()},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw UserServiceException.fromGraphQLError(result.exception!);
}
return User.fromJson(result.data!['updateMyUserProfileDetails']);
}
// Complete onboarding
Future<User> completeOnboarding(OnboardingData data) async {
const String mutation = '''
mutation CompleteOnboarding(\$input: OnboardingDataInput!) {
completeOnboarding(input: \$input) {
id
profile {
trainingGoal
trainingLevel
trainingFrequency
preferredSplit {
id
name
description
daysPerWeek
}
availableEquipment
age
gender
height
heightUnit
weight
weightUnit
dateOfBirth
healthkitAuthorized
notificationsEnabled
onboardingCompletedAt
onboardingVersion
}
onboardingStatus {
isCompleted
completedAt
shouldShowOnboarding
currentStep
version
missingFields
}
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'input': data.toJson()},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw UserServiceException.fromGraphQLError(result.exception!);
}
return User.fromJson(result.data!['completeOnboarding']);
}
// Update onboarding data (partial)
Future<User> updateOnboardingData(UpdateOnboardingData data) async {
const String mutation = '''
mutation UpdateOnboardingData(\$input: UpdateOnboardingDataInput!) {
updateOnboardingData(input: \$input) {
id
profile {
trainingGoal
trainingLevel
trainingFrequency
availableEquipment
onboardingCompletedAt
updatedAt
}
onboardingStatus {
isCompleted
shouldShowOnboarding
currentStep
missingFields
}
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'input': data.toJson()},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw UserServiceException.fromGraphQLError(result.exception!);
}
return User.fromJson(result.data!['updateOnboardingData']);
}
// Change password
Future<bool> changePassword(String currentPassword, String newPassword) async {
const String mutation = '''
mutation ChangePassword(\$input: ChangePasswordInput!) {
changePassword(input: \$input)
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {
'input': {
'currentPassword': currentPassword,
'newPassword': newPassword,
}
},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw UserServiceException.fromGraphQLError(result.exception!);
}
return result.data!['changePassword'] as bool;
}
}
// Input Data Classes
class UpdateBasicProfileData {
final String? firstName;
final String? lastName;
final String? bio;
UpdateBasicProfileData({this.firstName, this.lastName, this.bio});
Map<String, dynamic> toJson() => {
if (firstName != null) 'firstName': firstName,
if (lastName != null) 'lastName': lastName,
if (bio != null) 'bio': bio,
};
}
class UpdateDetailedProfileData {
final int? age;
final Gender? gender;
final double? height;
final HeightUnit? heightUnit;
final double? weight;
final WeightUnit? weightUnit;
UpdateDetailedProfileData({
this.age,
this.gender,
this.height,
this.heightUnit,
this.weight,
this.weightUnit,
});
Map<String, dynamic> toJson() => {
if (age != null) 'age': age,
if (gender != null) 'gender': gender!.name,
if (height != null) 'height': height,
if (heightUnit != null) 'heightUnit': heightUnit!.name,
if (weight != null) 'weight': weight,
if (weightUnit != null) 'weightUnit': weightUnit!.name,
};
}
class OnboardingData {
final TrainingGoal? trainingGoal;
final TrainingLevel? trainingLevel;
final int? trainingFrequency;
final WorkoutSplitInput? preferredSplit;
final List<Equipment>? availableEquipment;
final DateTime? dateOfBirth;
final Gender? gender;
final double? weight;
final WeightUnit? weightUnit;
final double? height;
final HeightUnit? heightUnit;
final bool? healthkitAuthorized;
final bool? notificationsEnabled;
OnboardingData({
this.trainingGoal,
this.trainingLevel,
this.trainingFrequency,
this.preferredSplit,
this.availableEquipment,
this.dateOfBirth,
this.gender,
this.weight,
this.weightUnit,
this.height,
this.heightUnit,
this.healthkitAuthorized,
this.notificationsEnabled,
});
Map<String, dynamic> toJson() => {
if (trainingGoal != null) 'trainingGoal': trainingGoal!.name,
if (trainingLevel != null) 'trainingLevel': trainingLevel!.name,
if (trainingFrequency != null) 'trainingFrequency': trainingFrequency,
if (preferredSplit != null) 'preferredSplit': preferredSplit!.toJson(),
if (availableEquipment != null)
'availableEquipment': availableEquipment!.map((e) => e.name).toList(),
if (dateOfBirth != null) 'dateOfBirth': dateOfBirth!.toIso8601String(),
if (gender != null) 'gender': gender!.name,
if (weight != null) 'weight': weight,
if (weightUnit != null) 'weightUnit': weightUnit!.name,
if (height != null) 'height': height,
if (heightUnit != null) 'heightUnit': heightUnit!.name,
if (healthkitAuthorized != null) 'healthkitAuthorized': healthkitAuthorized,
if (notificationsEnabled != null) 'notificationsEnabled': notificationsEnabled,
};
}
class WorkoutSplitInput {
final String id;
final String name;
final String? description;
final int daysPerWeek;
WorkoutSplitInput({
required this.id,
required this.name,
this.description,
required this.daysPerWeek,
});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (description != null) 'description': description,
'daysPerWeek': daysPerWeek,
};
}
// Exception handling
class UserServiceException implements Exception {
final String message;
final String code;
final int? statusCode;
UserServiceException({
required this.message,
required this.code,
this.statusCode,
});
factory UserServiceException.fromGraphQLError(OperationException error) {
if (error.graphqlErrors.isNotEmpty) {
final gqlError = error.graphqlErrors.first;
return UserServiceException(
message: gqlError.message,
code: gqlError.extensions?['code'] ?? 'UNKNOWN_ERROR',
);
}
return UserServiceException(
message: 'Network error occurred',
code: 'NETWORK_ERROR',
);
}
}
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ProfileScreen extends StatefulWidget {
@override
_ProfileScreenState createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _bioController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadUserData();
}
void _loadUserData() {
final user = context.read<UserProvider>().currentUser;
if (user != null) {
_firstNameController.text = user.firstName ?? '';
_lastNameController.text = user.lastName ?? '';
_bioController.text = user.bio ?? '';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Profile'),
actions: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () => Navigator.pushNamed(context, '/settings'),
),
],
),
body: Consumer<UserProvider>(
builder: (context, userProvider, child) {
final user = userProvider.currentUser;
if (user == null) {
return Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
// Profile Image Section
_buildProfileImageSection(user),
SizedBox(height: 24),
// Basic Information Form
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _firstNameController,
decoration: InputDecoration(
labelText: 'First Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'First name is required';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _lastNameController,
decoration: InputDecoration(
labelText: 'Last Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Last name is required';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _bioController,
decoration: InputDecoration(
labelText: 'Bio',
border: OutlineInputBorder(),
hintText: 'Tell us about yourself and your fitness journey',
),
maxLines: 3,
maxLength: 500,
),
],
),
),
SizedBox(height: 24),
// Fitness Profile Section
_buildFitnessProfileSection(user),
SizedBox(height: 24),
// Action Buttons
_buildActionButtons(),
SizedBox(height: 32),
],
),
);
},
),
);
}
Widget _buildProfileImageSection(User user) {
return Column(
children: [
CircleAvatar(
radius: 50,
backgroundImage: user.profileImageUrl != null
? NetworkImage(user.profileImageUrl!)
: null,
child: user.profileImageUrl == null
? Text(
user.username.substring(0, 1).toUpperCase(),
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
)
: null,
),
SizedBox(height: 8),
Text(
user.displayName,
style: Theme.of(context).textTheme.headline6,
),
Text(
'@${user.username}',
style: Theme.of(context).textTheme.subtitle2,
),
TextButton(
onPressed: _selectProfileImage,
child: Text('Change Photo'),
),
],
);
}
Widget _buildFitnessProfileSection(User user) {
final profile = user.profile;
return Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Fitness Profile',
style: Theme.of(context).textTheme.headline6,
),
SizedBox(height: 16),
if (profile?.hasPhysicalData == true) ...[
_buildInfoRow('Age', '${profile!.age} years'),
_buildInfoRow('Height', '${profile.height} ${profile.heightUnit?.name.toLowerCase()}'),
_buildInfoRow('Weight', '${profile.weight} ${profile.weightUnit?.name.toLowerCase()}'),
SizedBox(height: 8),
],
if (profile?.hasTrainingPrefs == true) ...[
_buildInfoRow('Training Goal', profile!.trainingGoal?.name.replaceAll('_', ' ')),
_buildInfoRow('Training Level', profile.trainingLevel?.name),
_buildInfoRow('Frequency', '${profile.trainingFrequency} days/week'),
SizedBox(height: 8),
],
if (profile?.preferredSplit != null) ...[
_buildInfoRow('Workout Split', profile!.preferredSplit!.name),
SizedBox(height: 8),
],
if (profile?.availableEquipment.isNotEmpty == true) ...[
Text(
'Available Equipment:',
style: TextStyle(fontWeight: FontWeight.w500),
),
SizedBox(height: 4),
Text(profile!.equipmentSummary),
SizedBox(height: 16),
],
ElevatedButton(
onPressed: () => Navigator.pushNamed(context, '/profile/fitness'),
child: Text('Update Fitness Profile'),
),
],
),
),
);
}
Widget _buildInfoRow(String label, String? value) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(fontWeight: FontWeight.w500),
),
Text(value ?? 'Not set'),
],
),
);
}
Widget _buildActionButtons() {
return Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _saveProfile,
child: _isLoading
? CircularProgressIndicator()
: Text('Save Changes'),
),
),
SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pushNamed(context, '/profile/change-password'),
child: Text('Change Password'),
),
),
],
);
}
Future<void> _saveProfile() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final data = UpdateBasicProfileData(
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
bio: _bioController.text.trim().isNotEmpty
? _bioController.text.trim()
: null,
);
await context.read<UserProvider>().updateBasicProfile(data);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Profile updated successfully')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update profile: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
void _selectProfileImage() async {
// Implement image picker logic
// This would typically use ImagePicker package
// and upload to object storage
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_bioController.dispose();
super.dispose();
}
}
import 'package:flutter/material.dart';
class OnboardingFlow extends StatefulWidget {
@override
_OnboardingFlowState createState() => _OnboardingFlowState();
}
class _OnboardingFlowState extends State<OnboardingFlow> {
final PageController _pageController = PageController();
int _currentStep = 0;
final int _totalSteps = 5;
// Onboarding data
final OnboardingData _data = OnboardingData();
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Progress indicator
_buildProgressIndicator(),
// Content
Expanded(
child: PageView(
controller: _pageController,
physics: NeverScrollableScrollPhysics(), // Prevent swiping
children: [
_buildWelcomeStep(),
_buildGoalsStep(),
_buildLevelStep(),
_buildPhysicalDataStep(),
_buildEquipmentStep(),
],
),
),
// Navigation buttons
_buildNavigationButtons(),
],
),
),
);
}
Widget _buildProgressIndicator() {
return Container(
padding: EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Step ${_currentStep + 1} of $_totalSteps'),
TextButton(
onPressed: _skipOnboarding,
child: Text('Skip'),
),
],
),
SizedBox(height: 8),
LinearProgressIndicator(
value: (_currentStep + 1) / _totalSteps,
),
],
),
);
}
Widget _buildWelcomeStep() {
return Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.fitness_center,
size: 80,
color: Theme.of(context).primaryColor,
),
SizedBox(height: 24),
Text(
'Welcome to OpenLift!',
style: Theme.of(context).textTheme.headline4,
textAlign: TextAlign.center,
),
SizedBox(height: 16),
Text(
'Let\'s set up your profile to give you the best workout experience.',
style: Theme.of(context).textTheme.body1,
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildGoalsStep() {
return Padding(
padding: EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'What\'s your primary fitness goal?',
style: Theme.of(context).textTheme.headline5,
),
SizedBox(height: 24),
...TrainingGoal.values.map((goal) => _buildGoalOption(goal)),
],
),
);
}
Widget _buildGoalOption(TrainingGoal goal) {
final isSelected = _data.trainingGoal == goal;
return Container(
margin: EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(_getGoalDisplayName(goal)),
subtitle: Text(_getGoalDescription(goal)),
leading: Radio<TrainingGoal>(
value: goal,
groupValue: _data.trainingGoal,
onChanged: (value) => setState(() => _data.trainingGoal = value),
),
onTap: () => setState(() => _data.trainingGoal = goal),
tileColor: isSelected ? Theme.of(context).primaryColor.withOpacity(0.1) : null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
),
),
);
}
Widget _buildLevelStep() {
return Padding(
padding: EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'What\'s your training experience?',
style: Theme.of(context).textTheme.headline5,
),
SizedBox(height: 24),
...TrainingLevel.values.map((level) => _buildLevelOption(level)),
SizedBox(height: 24),
Text(
'Training Frequency',
style: Theme.of(context).textTheme.headline6,
),
SizedBox(height: 16),
Text('How many days per week do you want to train?'),
SizedBox(height: 8),
Slider(
value: (_data.trainingFrequency ?? 3).toDouble(),
min: 1,
max: 7,
divisions: 6,
label: '${_data.trainingFrequency ?? 3} days',
onChanged: (value) => setState(() =>
_data.trainingFrequency = value.toInt()),
),
],
),
);
}
Widget _buildLevelOption(TrainingLevel level) {
final isSelected = _data.trainingLevel == level;
return Container(
margin: EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(_getLevelDisplayName(level)),
subtitle: Text(_getLevelDescription(level)),
leading: Radio<TrainingLevel>(
value: level,
groupValue: _data.trainingLevel,
onChanged: (value) => setState(() => _data.trainingLevel = value),
),
onTap: () => setState(() => _data.trainingLevel = level),
tileColor: isSelected ? Theme.of(context).primaryColor.withOpacity(0.1) : null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
),
),
);
}
Widget _buildPhysicalDataStep() {
return Padding(
padding: EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tell us about yourself',
style: Theme.of(context).textTheme.headline5,
),
SizedBox(height: 24),
// Age
TextFormField(
decoration: InputDecoration(
labelText: 'Age',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) => _data.age = int.tryParse(value),
),
SizedBox(height: 16),
// Gender
DropdownButtonFormField<Gender>(
value: _data.gender,
decoration: InputDecoration(
labelText: 'Gender',
border: OutlineInputBorder(),
),
items: Gender.values.map((gender) => DropdownMenuItem(
value: gender,
child: Text(_getGenderDisplayName(gender)),
)).toList(),
onChanged: (value) => setState(() => _data.gender = value),
),
SizedBox(height: 16),
// Weight
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
decoration: InputDecoration(
labelText: 'Weight',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) => _data.weight = double.tryParse(value),
),
),
SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<WeightUnit>(
value: _data.weightUnit ?? WeightUnit.KG,
decoration: InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
),
items: WeightUnit.values.map((unit) => DropdownMenuItem(
value: unit,
child: Text(unit.name),
)).toList(),
onChanged: (value) => setState(() => _data.weightUnit = value),
),
),
],
),
SizedBox(height: 16),
// Height
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
decoration: InputDecoration(
labelText: 'Height',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) => _data.height = double.tryParse(value),
),
),
SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<HeightUnit>(
value: _data.heightUnit ?? HeightUnit.CM,
decoration: InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
),
items: HeightUnit.values.map((unit) => DropdownMenuItem(
value: unit,
child: Text(unit.name),
)).toList(),
onChanged: (value) => setState(() => _data.heightUnit = value),
),
),
],
),
],
),
);
}
Widget _buildEquipmentStep() {
return Padding(
padding: EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'What equipment do you have access to?',
style: Theme.of(context).textTheme.headline5,
),
SizedBox(height: 16),
Text(
'Select all that apply:',
style: Theme.of(context).textTheme.body1,
),
SizedBox(height: 24),
Expanded(
child: ListView(
children: Equipment.values.map((equipment) =>
_buildEquipmentOption(equipment)
).toList(),
),
),
SizedBox(height: 16),
// App preferences
SwitchListTile(
title: Text('Enable notifications'),
subtitle: Text('Get reminders for your workouts'),
value: _data.notificationsEnabled ?? true,
onChanged: (value) => setState(() =>
_data.notificationsEnabled = value),
),
if (Theme.of(context).platform == TargetPlatform.iOS)
SwitchListTile(
title: Text('Connect to Apple Health'),
subtitle: Text('Sync your workouts with HealthKit'),
value: _data.healthkitAuthorized ?? false,
onChanged: (value) => setState(() =>
_data.healthkitAuthorized = value),
),
],
),
);
}
Widget _buildEquipmentOption(Equipment equipment) {
final isSelected = _data.availableEquipment?.contains(equipment) ?? false;
return CheckboxListTile(
title: Text(_getEquipmentDisplayName(equipment)),
value: isSelected,
onChanged: (value) {
setState(() {
if (_data.availableEquipment == null) {
_data.availableEquipment = [];
}
if (value == true) {
_data.availableEquipment!.add(equipment);
} else {
_data.availableEquipment!.remove(equipment);
}
});
},
);
}
Widget _buildNavigationButtons() {
return Container(
padding: EdgeInsets.all(16),
child: Row(
children: [
if (_currentStep > 0)
Expanded(
child: OutlinedButton(
onPressed: _previousStep,
child: Text('Back'),
),
),
if (_currentStep > 0) SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _canProceed() ? _nextStep : null,
child: Text(_currentStep == _totalSteps - 1 ? 'Complete' : 'Next'),
),
),
],
),
);
}
bool _canProceed() {
switch (_currentStep) {
case 0: return true; // Welcome step
case 1: return _data.trainingGoal != null;
case 2: return _data.trainingLevel != null && _data.trainingFrequency != null;
case 3: return true; // Physical data is optional
case 4: return true; // Equipment is optional
default: return false;
}
}
void _nextStep() async {
if (_currentStep < _totalSteps - 1) {
setState(() => _currentStep++);
_pageController.nextPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
// Complete onboarding
await _completeOnboarding();
}
}
void _previousStep() {
if (_currentStep > 0) {
setState(() => _currentStep--);
_pageController.previousPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Future<void> _completeOnboarding() async {
try {
await context.read<UserProvider>().completeOnboarding(_data);
Navigator.pushReplacementNamed(context, '/home');
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to complete onboarding: $e')),
);
}
}
void _skipOnboarding() async {
// Show confirmation dialog
final shouldSkip = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Skip Onboarding?'),
content: Text('You can complete your profile later in settings.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('Skip'),
),
],
),
);
if (shouldSkip == true) {
Navigator.pushReplacementNamed(context, '/home');
}
}
// Helper methods for display names
String _getGoalDisplayName(TrainingGoal goal) {
switch (goal) {
case TrainingGoal.MUSCLE_GAIN: return 'Muscle Gain';
case TrainingGoal.STRENGTH_GAIN: return 'Strength Gain';
case TrainingGoal.WEIGHT_LOSS: return 'Weight Loss';
case TrainingGoal.ENDURANCE: return 'Endurance';
case TrainingGoal.GENERAL_FITNESS: return 'General Fitness';
}
}
String _getGoalDescription(TrainingGoal goal) {
switch (goal) {
case TrainingGoal.MUSCLE_GAIN: return 'Build muscle mass and size';
case TrainingGoal.STRENGTH_GAIN: return 'Increase maximum strength';
case TrainingGoal.WEIGHT_LOSS: return 'Lose weight and body fat';
case TrainingGoal.ENDURANCE: return 'Improve cardiovascular fitness';
case TrainingGoal.GENERAL_FITNESS: return 'Overall health and wellness';
}
}
String _getLevelDisplayName(TrainingLevel level) {
switch (level) {
case TrainingLevel.BEGINNER: return 'Beginner';
case TrainingLevel.INTERMEDIATE: return 'Intermediate';
case TrainingLevel.ADVANCED: return 'Advanced';
}
}
String _getLevelDescription(TrainingLevel level) {
switch (level) {
case TrainingLevel.BEGINNER: return 'New to fitness or returning after a break';
case TrainingLevel.INTERMEDIATE: return '6+ months of consistent training';
case TrainingLevel.ADVANCED: return '2+ years of serious training';
}
}
String _getGenderDisplayName(Gender gender) {
switch (gender) {
case Gender.MALE: return 'Male';
case Gender.FEMALE: return 'Female';
case Gender.NON_BINARY: return 'Non-binary';
case Gender.PREFER_NOT_TO_SAY: return 'Prefer not to say';
}
}
String _getEquipmentDisplayName(Equipment equipment) {
switch (equipment) {
case Equipment.BARBELL: return 'Barbell';
case Equipment.DUMBBELLS: return 'Dumbbells';
case Equipment.KETTLEBELLS: return 'Kettlebells';
case Equipment.RESISTANCE_BANDS: return 'Resistance Bands';
case Equipment.PULL_UP_BAR: return 'Pull-up Bar';
case Equipment.BENCH: return 'Bench';
case Equipment.SQUAT_RACK: return 'Squat Rack';
case Equipment.CABLE_MACHINE: return 'Cable Machine';
case Equipment.BODYWEIGHT_ONLY: return 'Bodyweight Only';
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
}
// Temporary class for collecting onboarding data
class OnboardingData {
TrainingGoal? trainingGoal;
TrainingLevel? trainingLevel;
int? trainingFrequency;
int? age;
Gender? gender;
double? weight;
WeightUnit? weightUnit;
double? height;
HeightUnit? heightUnit;
List<Equipment>? availableEquipment;
bool? healthkitAuthorized;
bool? notificationsEnabled;
// Convert to proper OnboardingData input format when complete
}
🛡️ Business Logic & Validation
Profile Completeness Rules
The system uses server-side business logic to determine profile completeness and onboarding status:
interface ProfileCompletionRules {
// Required for basic profile completion
basicRequired: ['firstName', 'lastName'];
// Required for fitness profile completion
fitnessRequired: [
'trainingGoal',
'trainingLevel',
'trainingFrequency',
'age',
'gender'
];
// Optional but recommended
recommended: [
'bio',
'weight',
'height',
'availableEquipment',
'preferredSplit'
];
}
Onboarding Flow Business Rules
- Step Validation: Each onboarding step has specific validation rules
- Progressive Disclosure: Advanced options shown based on previous selections
- Equipment-Based Recommendations: Program suggestions based on available equipment
- Goal-Based Customization: Training parameters adjusted based on selected goals
Data Privacy & Security
- Profile Images: Signed URLs with expiration for secure access
- Personal Data: Height, weight, age stored with user consent
- Data Retention: User can delete profile data at any time
- Privacy Controls: Users control visibility of profile information
📊 Error Handling
Common Error Scenarios
- Validation Errors
- Onboarding Errors
- Profile Image Errors
class ProfileValidationException implements Exception {
final Map<String, String> fieldErrors;
ProfileValidationException(this.fieldErrors);
String? getFieldError(String field) => fieldErrors[field];
bool hasFieldError(String field) => fieldErrors.containsKey(field);
}
// Usage in forms
try {
await userService.updateProfile(data);
} on ProfileValidationException catch (e) {
setState(() {
_firstNameError = e.getFieldError('firstName');
_bioError = e.getFieldError('bio');
});
}
Common Validation Errors:
INVALID_AGE- Age must be between 13-120INVALID_WEIGHT- Weight must be positive numberINVALID_HEIGHT- Height must be positive numberBIO_TOO_LONG- Bio exceeds 500 charactersINVALID_TRAINING_FREQUENCY- Must be 1-7 days per week
enum OnboardingErrorType {
missingRequiredFields,
invalidStep,
alreadyCompleted,
systemError,
}
class OnboardingException implements Exception {
final OnboardingErrorType type;
final String message;
final List<String> missingFields;
OnboardingException({
required this.type,
required this.message,
this.missingFields = const [],
});
}
// Usage
try {
await userService.completeOnboarding(data);
} on OnboardingException catch (e) {
switch (e.type) {
case OnboardingErrorType.missingRequiredFields:
_showMissingFieldsDialog(e.missingFields);
break;
case OnboardingErrorType.alreadyCompleted:
Navigator.pushReplacementNamed(context, '/home');
break;
default:
_showErrorDialog(e.message);
}
}
enum ImageUploadError {
fileTooLarge,
invalidFormat,
networkError,
storageError,
}
class ProfileImageException implements Exception {
final ImageUploadError type;
final String message;
ProfileImageException({required this.type, required this.message});
}
// Usage
Future<void> uploadProfileImage(File imageFile) async {
try {
// Validate file size (max 5MB)
if (await imageFile.length() > 5 * 1024 * 1024) {
throw ProfileImageException(
type: ImageUploadError.fileTooLarge,
message: 'Image must be smaller than 5MB',
);
}
// Validate format
final extension = path.extension(imageFile.path).toLowerCase();
if (!['.jpg', '.jpeg', '.png'].contains(extension)) {
throw ProfileImageException(
type: ImageUploadError.invalidFormat,
message: 'Only JPG and PNG images are supported',
);
}
await objectStorageService.uploadProfileImage(imageFile);
} on ProfileImageException {
rethrow;
} catch (e) {
throw ProfileImageException(
type: ImageUploadError.networkError,
message: 'Failed to upload image: $e',
);
}
}
🔄 State Management
UserProvider Example
import 'package:flutter/material.dart';
class UserProvider extends ChangeNotifier {
User? _currentUser;
bool _isLoading = false;
String? _error;
User? get currentUser => _currentUser;
bool get isLoading => _isLoading;
String? get error => _error;
bool get isAuthenticated => _currentUser != null;
bool get needsOnboarding => _currentUser?.needsOnboarding ?? false;
Future<void> loadCurrentUser() async {
_setLoading(true);
try {
_currentUser = await _userService.getCurrentUser();
_error = null;
} catch (e) {
_error = e.toString();
_currentUser = null;
} finally {
_setLoading(false);
}
}
Future<void> updateBasicProfile(UpdateBasicProfileData data) async {
try {
final updatedUser = await _userService.updateBasicProfile(data);
_currentUser = updatedUser;
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
}
}
Future<void> completeOnboarding(OnboardingData data) async {
_setLoading(true);
try {
_currentUser = await _userService.completeOnboarding(data);
_error = null;
} catch (e) {
_error = e.toString();
rethrow;
} finally {
_setLoading(false);
}
}
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void clearError() {
_error = null;
notifyListeners();
}
}
🚀 Testing
Unit Tests
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
void main() {
group('UserService', () {
late MockGraphQLClient mockClient;
late UserService userService;
setUp(() {
mockClient = MockGraphQLClient();
userService = UserService(mockClient);
});
test('should return current user on successful query', () async {
// Arrange
final userData = {
'me': {
'id': 'user123',
'email': 'test@example.com',
'username': 'testuser',
'firstName': 'Test',
'lastName': 'User',
'role': 'USER',
'createdAt': '2023-01-01T00:00:00Z',
'profile': null,
'onboardingStatus': {
'isCompleted': false,
'shouldShowOnboarding': true,
'currentStep': 0,
'version': 'v2.1',
'missingFields': ['trainingGoal'],
}
}
};
when(mockClient.query(any)).thenAnswer((_) async => QueryResult(
data: userData,
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act
final user = await userService.getCurrentUser();
// Assert
expect(user.id, equals('user123'));
expect(user.needsOnboarding, isTrue);
expect(user.onboardingStatus.currentStep, equals(0));
});
test('should update basic profile successfully', () async {
// Test implementation
});
test('should complete onboarding with valid data', () async {
// Test implementation
});
});
}
📚 Migration Notes
- All user IDs are MongoDB ObjectId strings
- Profile data uses embedded MongoDB documents
- Profile images use signed URLs with expiration
- Onboarding system is versioned for future updates
- Equipment preferences stored as arrays for multi-selection
- Age calculated from dateOfBirth server-side
- All enum values validated server-side
🆘 Support
For user management questions:
- Check onboarding status logic for navigation decisions
- Verify profile image URL expiration and refresh logic
- Ensure proper enum value handling across client/server
- Contact backend team for profile completeness rule changes
Ready to manage user profiles? Check out the Program Plans API for fitness program integration.