Skip to main content

Client Architecture Patterns

This guide outlines recommended architectural patterns for OpenLift client applications, focusing on service layer design, state management, data flow patterns, and maintaining proper separation of concerns.

⚠️ CRITICAL: THIN CLIENT ARCHITECTURE ENFORCEMENT

What Client Applications SHOULD implement:

  • ✅ Service layer for GraphQL operations
  • ✅ State management for UI data
  • ✅ Caching layer for offline support
  • ✅ UI components and navigation
  • ✅ Platform-specific integrations (HealthKit, Google Fit)

What Client Applications MUST NOT implement:

  • ❌ Business logic or calculations
  • ❌ Data validation beyond UI feedback
  • ❌ Progressive workout algorithms
  • ❌ Fitness program generation
  • ❌ Analytics or reporting logic

Architecture Overview

OpenLift client applications follow a layered architecture that ensures clear separation of concerns and maintains the critical thin client boundary:

Architecture Layers

┌─────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ • UI Components • Navigation • Platform Integration │
├─────────────────────────────────────────────────────────┤
│ State Management Layer │
│ • Providers • BLoC • Redux • Local State │
├─────────────────────────────────────────────────────────┤
│ Service Layer │
│ • GraphQL Services • Authentication • Caching │
├─────────────────────────────────────────────────────────┤
│ Data Layer │
│ • Local Storage • Cache • Offline Sync │
└─────────────────────────────────────────────────────────┘
↕ GraphQL API
┌─────────────────────────────────────────────────────────┐
│ OpenLift Backend │
│ • Business Logic • Data Processing • Intelligence │
└─────────────────────────────────────────────────────────┘

1. Service Layer Architecture

The service layer is the primary interface between your application and the OpenLift GraphQL API. It handles all data fetching, caching, and error handling.

Base Service Pattern

// lib/services/base/base_service.dart
import 'package:graphql_flutter/graphql_flutter.dart';

abstract class BaseService {
final GraphQLClient _client;
final String _serviceName;

BaseService({
required GraphQLClient client,
required String serviceName,
}) : _client = client, _serviceName = serviceName;

/// Execute GraphQL query with error handling and logging
Future<T> executeQuery<T>({
required String operationName,
required String query,
Map<String, dynamic> variables = const {},
required T Function(Map<String, dynamic> data) parser,
CachePolicy? cachePolicy,
}) async {
try {
_logOperation('Query', operationName, variables);

final result = await _client.query(
QueryOptions(
document: gql(query),
variables: variables,
cachePolicy: cachePolicy ?? CachePolicy.cacheFirst,
errorPolicy: ErrorPolicy.all,
),
);

if (result.hasException) {
_handleException(operationName, result.exception!);
throw ServiceException(
message: _parseErrorMessage(result.exception!),
operation: operationName,
service: _serviceName,
);
}

_logSuccess(operationName);
return parser(result.data!);
} catch (e) {
if (e is ServiceException) rethrow;
_logError(operationName, e);
throw ServiceException(
message: 'Operation failed: ${e.toString()}',
operation: operationName,
service: _serviceName,
originalException: e,
);
}
}

/// Execute GraphQL mutation with error handling and logging
Future<T> executeMutation<T>({
required String operationName,
required String mutation,
Map<String, dynamic> variables = const {},
required T Function(Map<String, dynamic> data) parser,
}) async {
try {
_logOperation('Mutation', operationName, variables);

final result = await _client.mutate(
MutationOptions(
document: gql(mutation),
variables: variables,
errorPolicy: ErrorPolicy.all,
),
);

if (result.hasException) {
_handleException(operationName, result.exception!);
throw ServiceException(
message: _parseErrorMessage(result.exception!),
operation: operationName,
service: _serviceName,
);
}

_logSuccess(operationName);
return parser(result.data!);
} catch (e) {
if (e is ServiceException) rethrow;
_logError(operationName, e);
throw ServiceException(
message: 'Operation failed: ${e.toString()}',
operation: operationName,
service: _serviceName,
originalException: e,
);
}
}

/// Execute paginated query with automatic pagination handling
Future<PaginatedResult<T>> executePaginatedQuery<T>({
required String operationName,
required String query,
required Map<String, dynamic> variables,
required T Function(Map<String, dynamic> node) nodeParser,
String connectionField = 'edges',
int? first,
String? after,
}) async {
final paginationVariables = {
...variables,
if (first != null) 'first': first,
if (after != null) 'after': after,
};

return executeQuery<PaginatedResult<T>>(
operationName: operationName,
query: query,
variables: paginationVariables,
parser: (data) => _parsePaginatedResult(data, connectionField, nodeParser),
);
}

// Private helper methods
void _logOperation(String type, String operation, Map<String, dynamic> variables) {
if (AppConfig.enableDebugLogs) {
print('🔍 [$_serviceName] $type: $operation');
if (variables.isNotEmpty) {
print('📝 Variables: $variables');
}
}
}

void _logSuccess(String operation) {
if (AppConfig.enableDebugLogs) {
print('✅ [$_serviceName] Success: $operation');
}
}

void _logError(String operation, dynamic error) {
print('❌ [$_serviceName] Error in $operation: $error');
}

void _handleException(String operation, OperationException exception) {
// Handle specific error types
final errors = exception.graphqlErrors;
for (final error in errors) {
final code = error.extensions?['code'];
switch (code) {
case 'UNAUTHENTICATED':
EventBus().emit('auth.unauthenticated');
break;
case 'RATE_LIMITED':
EventBus().emit('api.rateLimited', {'operation': operation});
break;
case 'SERVER_ERROR':
EventBus().emit('api.serverError', {'operation': operation});
break;
}
}
}

String _parseErrorMessage(OperationException exception) {
if (exception.graphqlErrors.isNotEmpty) {
return exception.graphqlErrors.first.message;
}
if (exception.linkException != null) {
return 'Network error: ${exception.linkException}';
}
return 'Unknown error occurred';
}

PaginatedResult<T> _parsePaginatedResult<T>(
Map<String, dynamic> data,
String connectionField,
T Function(Map<String, dynamic>) nodeParser,
) {
final connection = data.values.first as Map<String, dynamic>;
final edges = (connection['edges'] as List).cast<Map<String, dynamic>>();
final pageInfo = connection['pageInfo'] as Map<String, dynamic>;

final items = edges.map((edge) => nodeParser(edge['node'])).toList();

return PaginatedResult<T>(
items: items,
totalCount: connection['totalCount'] as int? ?? items.length,
hasNextPage: pageInfo['hasNextPage'] as bool,
hasPreviousPage: pageInfo['hasPreviousPage'] as bool,
startCursor: pageInfo['startCursor'] as String?,
endCursor: pageInfo['endCursor'] as String?,
);
}
}

// Service exceptions
class ServiceException implements Exception {
final String message;
final String operation;
final String service;
final dynamic originalException;

const ServiceException({
required this.message,
required this.operation,
required this.service,
this.originalException,
});

@override
String toString() => '[$service] $operation: $message';
}

// Paginated result wrapper
class PaginatedResult<T> {
final List<T> items;
final int totalCount;
final bool hasNextPage;
final bool hasPreviousPage;
final String? startCursor;
final String? endCursor;

const PaginatedResult({
required this.items,
required this.totalCount,
required this.hasNextPage,
required this.hasPreviousPage,
this.startCursor,
this.endCursor,
});

bool get isEmpty => items.isEmpty;
bool get isNotEmpty => items.isNotEmpty;
int get length => items.length;
}

Workout Service Implementation

// lib/services/workout_service.dart
import 'package:flutter/foundation.dart';

class WorkoutService extends BaseService {
WorkoutService({required GraphQLClient client})
: super(client: client, serviceName: 'WorkoutService');

// GraphQL Operations
static const String _getWorkoutHistoryQuery = '''
query GetWorkoutHistory(\$userId: ID!, \$first: Int, \$after: String, \$dateRange: DateRangeInput) {
user(id: \$userId) {
id
workoutHistory(first: \$first, after: \$after, dateRange: \$dateRange) {
totalCount
edges {
cursor
node {
id
programName
workoutName
startedAt
completedAt
duration
totalVolume
avgRPE
exercises {
id
exerciseName
sets {
id
reps
weight
rpe
restTime
notes
}
}
notes
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
''';

static const String _startWorkoutMutation = '''
mutation StartWorkout(\$input: StartWorkoutInput!) {
startWorkout(input: \$input) {
workoutSession {
id
programName
workoutName
startedAt
currentExercise {
id
exerciseName
targetSets
targetReps
targetWeight
targetRPE
instructions
}
remainingExercises {
id
exerciseName
targetSets
}
}
}
}
''';

static const String _logWorkoutSetMutation = '''
mutation LogWorkoutSet(\$input: LogWorkoutSetInput!) {
logWorkoutSet(input: \$input) {
workoutSet {
id
reps
weight
rpe
restTime
completedAt
}
nextRecommendation {
weight
reps
rpe
restTime
message
}
}
}
''';

static const String _completeWorkoutMutation = '''
mutation CompleteWorkout(\$workoutId: ID!, \$notes: String) {
completeWorkout(workoutId: \$workoutId, notes: \$notes) {
workoutSession {
id
completedAt
duration
totalVolume
avgRPE
workoutSummary {
exercisesCompleted
setsCompleted
totalReps
maxWeight
achievements {
type
description
value
}
}
}
progressUpdate {
strengthGains
volumeIncrease
newPersonalRecords {
exerciseId
exerciseName
recordType
value
previousValue
}
}
}
}
''';

// ═══════════════════════════════════════════════════════════════════════════
// Workout History Operations
// ═══════════════════════════════════════════════════════════════════════════

/// Get user workout history with pagination and filtering
Future<PaginatedResult<WorkoutSession>> getWorkoutHistory({
required String userId,
int first = 20,
String? after,
DateRangeInput? dateRange,
}) async {
return executePaginatedQuery<WorkoutSession>(
operationName: 'GetWorkoutHistory',
query: _getWorkoutHistoryQuery,
variables: {
'userId': userId,
if (dateRange != null) 'dateRange': dateRange.toJson(),
},
connectionField: 'workoutHistory',
nodeParser: (node) => WorkoutSession.fromJson(node),
first: first,
after: after,
);
}

/// Get specific workout session by ID
Future<WorkoutSession> getWorkoutSession(String workoutId) async {
const query = '''
query GetWorkoutSession(\$id: ID!) {
workoutSession(id: \$id) {
id
programName
workoutName
startedAt
completedAt
duration
totalVolume
avgRPE
exercises {
id
exerciseName
sets {
id
reps
weight
rpe
restTime
notes
completedAt
}
personalRecords {
type
value
achievedAt
}
}
notes
workoutSummary {
exercisesCompleted
setsCompleted
totalReps
maxWeight
achievements {
type
description
value
}
}
}
}
''';

return executeQuery<WorkoutSession>(
operationName: 'GetWorkoutSession',
query: query,
variables: {'id': workoutId},
parser: (data) => WorkoutSession.fromJson(data['workoutSession']),
cachePolicy: CachePolicy.cacheFirst,
);
}

// ═══════════════════════════════════════════════════════════════════════════
// Active Workout Operations
// ═══════════════════════════════════════════════════════════════════════════

/// Start a new workout session
Future<ActiveWorkoutSession> startWorkout({
required String userId,
required String programId,
required String workoutId,
Map<String, dynamic>? customizations,
}) async {
return executeMutation<ActiveWorkoutSession>(
operationName: 'StartWorkout',
mutation: _startWorkoutMutation,
variables: {
'input': {
'userId': userId,
'programId': programId,
'workoutId': workoutId,
if (customizations != null) 'customizations': customizations,
}
},
parser: (data) => ActiveWorkoutSession.fromJson(data['startWorkout']['workoutSession']),
);
}

/// Log a workout set with performance data
Future<WorkoutSetResult> logWorkoutSet({
required String workoutId,
required String exerciseId,
required int setNumber,
required int reps,
required double weight,
int? rpe,
int? restTime,
String? notes,
}) async {
return executeMutation<WorkoutSetResult>(
operationName: 'LogWorkoutSet',
mutation: _logWorkoutSetMutation,
variables: {
'input': {
'workoutId': workoutId,
'exerciseId': exerciseId,
'setNumber': setNumber,
'reps': reps,
'weight': weight,
if (rpe != null) 'rpe': rpe,
if (restTime != null) 'restTime': restTime,
if (notes != null) 'notes': notes,
}
},
parser: (data) => WorkoutSetResult.fromJson(data['logWorkoutSet']),
);
}

/// Complete active workout session
Future<WorkoutCompletion> completeWorkout({
required String workoutId,
String? notes,
}) async {
return executeMutation<WorkoutCompletion>(
operationName: 'CompleteWorkout',
mutation: _completeWorkoutMutation,
variables: {
'workoutId': workoutId,
if (notes != null) 'notes': notes,
},
parser: (data) => WorkoutCompletion.fromJson(data['completeWorkout']),
);
}

/// Cancel/abandon active workout session
Future<void> cancelWorkout(String workoutId) async {
const mutation = '''
mutation CancelWorkout(\$workoutId: ID!) {
cancelWorkout(workoutId: \$workoutId) {
success
message
}
}
''';

await executeMutation<void>(
operationName: 'CancelWorkout',
mutation: mutation,
variables: {'workoutId': workoutId},
parser: (data) => null, // No return value needed
);
}

// ═══════════════════════════════════════════════════════════════════════════
// Workout Analytics (Read-Only Data Consumption)
// ═══════════════════════════════════════════════════════════════════════════

/// Get workout analytics for user (server-calculated data only)
Future<WorkoutAnalytics> getWorkoutAnalytics({
required String userId,
DateRangeInput? dateRange,
String? exerciseId,
}) async {
const query = '''
query GetWorkoutAnalytics(\$userId: ID!, \$dateRange: DateRangeInput, \$exerciseId: ID) {
user(id: \$userId) {
id
workoutAnalytics(dateRange: \$dateRange, exerciseId: \$exerciseId) {
totalWorkouts
totalVolume
avgWorkoutDuration
avgRPE
strengthProgress {
exerciseId
exerciseName
progressPercentage
volumeIncrease
strengthIncrease
}
weeklyTrends {
week
workouts
volume
avgDuration
}
personalRecords {
exerciseId
exerciseName
recordType
value
achievedAt
}
}
}
}
''';

return executeQuery<WorkoutAnalytics>(
operationName: 'GetWorkoutAnalytics',
query: query,
variables: {
'userId': userId,
if (dateRange != null) 'dateRange': dateRange.toJson(),
if (exerciseId != null) 'exerciseId': exerciseId,
},
parser: (data) => WorkoutAnalytics.fromJson(data['user']['workoutAnalytics']),
cachePolicy: CachePolicy.cacheFirst,
);
}
}

// ═══════════════════════════════════════════════════════════════════════════
// Data Models (DTOs matching GraphQL responses)
// ═══════════════════════════════════════════════════════════════════════════

class WorkoutSession {
final String id;
final String programName;
final String workoutName;
final DateTime startedAt;
final DateTime? completedAt;
final Duration? duration;
final double? totalVolume;
final double? avgRPE;
final List<WorkoutExercise> exercises;
final String? notes;
final WorkoutSummary? workoutSummary;

const WorkoutSession({
required this.id,
required this.programName,
required this.workoutName,
required this.startedAt,
this.completedAt,
this.duration,
this.totalVolume,
this.avgRPE,
required this.exercises,
this.notes,
this.workoutSummary,
});

factory WorkoutSession.fromJson(Map<String, dynamic> json) {
return WorkoutSession(
id: json['id'],
programName: json['programName'],
workoutName: json['workoutName'],
startedAt: DateTime.parse(json['startedAt']),
completedAt: json['completedAt'] != null ? DateTime.parse(json['completedAt']) : null,
duration: json['duration'] != null ? Duration(seconds: json['duration']) : null,
totalVolume: json['totalVolume']?.toDouble(),
avgRPE: json['avgRPE']?.toDouble(),
exercises: (json['exercises'] as List).map((e) => WorkoutExercise.fromJson(e)).toList(),
notes: json['notes'],
workoutSummary: json['workoutSummary'] != null
? WorkoutSummary.fromJson(json['workoutSummary'])
: null,
);
}

bool get isCompleted => completedAt != null;
bool get isActive => completedAt == null;
}

class ActiveWorkoutSession {
final String id;
final String programName;
final String workoutName;
final DateTime startedAt;
final WorkoutExercise currentExercise;
final List<WorkoutExercise> remainingExercises;

const ActiveWorkoutSession({
required this.id,
required this.programName,
required this.workoutName,
required this.startedAt,
required this.currentExercise,
required this.remainingExercises,
});

factory ActiveWorkoutSession.fromJson(Map<String, dynamic> json) {
return ActiveWorkoutSession(
id: json['id'],
programName: json['programName'],
workoutName: json['workoutName'],
startedAt: DateTime.parse(json['startedAt']),
currentExercise: WorkoutExercise.fromJson(json['currentExercise']),
remainingExercises: (json['remainingExercises'] as List)
.map((e) => WorkoutExercise.fromJson(e)).toList(),
);
}

int get totalExercises => remainingExercises.length + 1;
int get completedExercises => totalExercises - remainingExercises.length;
double get progressPercentage => completedExercises / totalExercises;
}

class WorkoutSetResult {
final WorkoutSet workoutSet;
final SetRecommendation? nextRecommendation;

const WorkoutSetResult({
required this.workoutSet,
this.nextRecommendation,
});

factory WorkoutSetResult.fromJson(Map<String, dynamic> json) {
return WorkoutSetResult(
workoutSet: WorkoutSet.fromJson(json['workoutSet']),
nextRecommendation: json['nextRecommendation'] != null
? SetRecommendation.fromJson(json['nextRecommendation'])
: null,
);
}
}

class SetRecommendation {
final double? weight;
final int? reps;
final int? rpe;
final int? restTime;
final String message;

const SetRecommendation({
this.weight,
this.reps,
this.rpe,
this.restTime,
required this.message,
});

factory SetRecommendation.fromJson(Map<String, dynamic> json) {
return SetRecommendation(
weight: json['weight']?.toDouble(),
reps: json['reps'],
rpe: json['rpe'],
restTime: json['restTime'],
message: json['message'],
);
}
}

class DateRangeInput {
final DateTime startDate;
final DateTime endDate;

const DateRangeInput({
required this.startDate,
required this.endDate,
});

Map<String, dynamic> toJson() {
return {
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
};
}
}

2. State Management Architecture

Provider Pattern Implementation

// lib/providers/workout_provider.dart
import 'package:flutter/foundation.dart';

class WorkoutProvider extends ChangeNotifier {
final WorkoutService _workoutService;
final String _userId;

// State management
PaginatedResult<WorkoutSession>? _workoutHistory;
ActiveWorkoutSession? _activeWorkout;
WorkoutAnalytics? _analytics;

// Loading states
bool _isLoadingHistory = false;
bool _isLoadingAnalytics = false;
bool _isActiveWorkoutLoading = false;

// Error states
String? _historyError;
String? _analyticsError;
String? _activeWorkoutError;

WorkoutProvider({
required WorkoutService workoutService,
required String userId,
}) : _workoutService = workoutService, _userId = userId;

// Getters
PaginatedResult<WorkoutSession>? get workoutHistory => _workoutHistory;
ActiveWorkoutSession? get activeWorkout => _activeWorkout;
WorkoutAnalytics? get analytics => _analytics;

bool get isLoadingHistory => _isLoadingHistory;
bool get isLoadingAnalytics => _isLoadingAnalytics;
bool get isActiveWorkoutLoading => _isActiveWorkoutLoading;

String? get historyError => _historyError;
String? get analyticsError => _analyticsError;
String? get activeWorkoutError => _activeWorkoutError;

bool get hasActiveWorkout => _activeWorkout != null;

// ═══════════════════════════════════════════════════════════════════════════
// Workout History Management
// ═══════════════════════════════════════════════════════════════════════════

/// Load workout history with pagination
Future<void> loadWorkoutHistory({
bool refresh = false,
DateRangeInput? dateRange,
}) async {
if (_isLoadingHistory) return;

_isLoadingHistory = true;
_historyError = null;

if (refresh) {
_workoutHistory = null;
}

notifyListeners();

try {
final result = await _workoutService.getWorkoutHistory(
userId: _userId,
dateRange: dateRange,
first: 20,
);

_workoutHistory = result;
_historyError = null;
} catch (e) {
_historyError = e.toString();
if (kDebugMode) {
print('Error loading workout history: $e');
}
} finally {
_isLoadingHistory = false;
notifyListeners();
}
}

/// Load more workout history (pagination)
Future<void> loadMoreHistory() async {
if (_isLoadingHistory || _workoutHistory == null || !_workoutHistory!.hasNextPage) {
return;
}

_isLoadingHistory = true;
notifyListeners();

try {
final result = await _workoutService.getWorkoutHistory(
userId: _userId,
first: 20,
after: _workoutHistory!.endCursor,
);

// Merge with existing data
_workoutHistory = PaginatedResult<WorkoutSession>(
items: [..._workoutHistory!.items, ...result.items],
totalCount: result.totalCount,
hasNextPage: result.hasNextPage,
hasPreviousPage: result.hasPreviousPage,
startCursor: _workoutHistory!.startCursor,
endCursor: result.endCursor,
);

} catch (e) {
_historyError = e.toString();
} finally {
_isLoadingHistory = false;
notifyListeners();
}
}

// ═══════════════════════════════════════════════════════════════════════════
// Active Workout Management
// ═══════════════════════════════════════════════════════════════════════════

/// Start a new workout session
Future<bool> startWorkout({
required String programId,
required String workoutId,
Map<String, dynamic>? customizations,
}) async {
if (_isActiveWorkoutLoading) return false;

_isActiveWorkoutLoading = true;
_activeWorkoutError = null;
notifyListeners();

try {
final result = await _workoutService.startWorkout(
userId: _userId,
programId: programId,
workoutId: workoutId,
customizations: customizations,
);

_activeWorkout = result;
_activeWorkoutError = null;

// Emit event for other parts of the app
EventBus().emit('workout.started', {'workoutId': result.id});

return true;
} catch (e) {
_activeWorkoutError = e.toString();
if (kDebugMode) {
print('Error starting workout: $e');
}
return false;
} finally {
_isActiveWorkoutLoading = false;
notifyListeners();
}
}

/// Log a workout set
Future<SetRecommendation?> logWorkoutSet({
required String exerciseId,
required int setNumber,
required int reps,
required double weight,
int? rpe,
int? restTime,
String? notes,
}) async {
if (_activeWorkout == null) return null;

try {
final result = await _workoutService.logWorkoutSet(
workoutId: _activeWorkout!.id,
exerciseId: exerciseId,
setNumber: setNumber,
reps: reps,
weight: weight,
rpe: rpe,
restTime: restTime,
notes: notes,
);

// Emit event for real-time updates
EventBus().emit('workout.setLogged', {
'workoutId': _activeWorkout!.id,
'exerciseId': exerciseId,
'setNumber': setNumber,
});

return result.nextRecommendation;
} catch (e) {
_activeWorkoutError = e.toString();
notifyListeners();
return null;
}
}

/// Complete active workout
Future<WorkoutCompletion?> completeWorkout({String? notes}) async {
if (_activeWorkout == null) return null;

_isActiveWorkoutLoading = true;
notifyListeners();

try {
final result = await _workoutService.completeWorkout(
workoutId: _activeWorkout!.id,
notes: notes,
);

_activeWorkout = null;
_activeWorkoutError = null;

// Refresh workout history to include new workout
await loadWorkoutHistory(refresh: true);

// Emit completion event
EventBus().emit('workout.completed', {
'workoutId': result.workoutSession.id,
'summary': result.workoutSession.workoutSummary,
});

return result;
} catch (e) {
_activeWorkoutError = e.toString();
return null;
} finally {
_isActiveWorkoutLoading = false;
notifyListeners();
}
}

/// Cancel active workout
Future<bool> cancelWorkout() async {
if (_activeWorkout == null) return false;

try {
await _workoutService.cancelWorkout(_activeWorkout!.id);
_activeWorkout = null;
_activeWorkoutError = null;

notifyListeners();

// Emit cancellation event
EventBus().emit('workout.cancelled');

return true;
} catch (e) {
_activeWorkoutError = e.toString();
notifyListeners();
return false;
}
}

// ═══════════════════════════════════════════════════════════════════════════
// Analytics Management (Read-Only Server Data)
// ═══════════════════════════════════════════════════════════════════════════

/// Load workout analytics (server-calculated data only)
Future<void> loadAnalytics({
DateRangeInput? dateRange,
String? exerciseId,
}) async {
if (_isLoadingAnalytics) return;

_isLoadingAnalytics = true;
_analyticsError = null;
notifyListeners();

try {
final result = await _workoutService.getWorkoutAnalytics(
userId: _userId,
dateRange: dateRange,
exerciseId: exerciseId,
);

_analytics = result;
_analyticsError = null;
} catch (e) {
_analyticsError = e.toString();
if (kDebugMode) {
print('Error loading analytics: $e');
}
} finally {
_isLoadingAnalytics = false;
notifyListeners();
}
}

/// Refresh all data
Future<void> refreshAll() async {
await Future.wait([
loadWorkoutHistory(refresh: true),
loadAnalytics(),
]);
}

/// Clear all error states
void clearErrors() {
_historyError = null;
_analyticsError = null;
_activeWorkoutError = null;
notifyListeners();
}
}

3. Data Layer Architecture

Caching and Offline Support

// lib/services/cache_service.dart
import 'package:hive_flutter/hive_flutter.dart';
import 'dart:convert';

class CacheService {
static const String _workoutCacheBox = 'workout_cache';
static const String _userCacheBox = 'user_cache';
static const String _programCacheBox = 'program_cache';

static Box<String>? _workoutCache;
static Box<String>? _userCache;
static Box<String>? _programCache;

/// Initialize cache storage
static Future<void> initialize() async {
await Hive.initFlutter();

_workoutCache = await Hive.openBox<String>(_workoutCacheBox);
_userCache = await Hive.openBox<String>(_userCacheBox);
_programCache = await Hive.openBox<String>(_programCacheBox);
}

/// Cache workout data with expiration
static Future<void> cacheWorkoutData({
required String key,
required Map<String, dynamic> data,
Duration? ttl,
}) async {
final cacheEntry = CacheEntry(
data: data,
timestamp: DateTime.now(),
ttl: ttl ?? const Duration(hours: 2),
);

await _workoutCache?.put(key, json.encode(cacheEntry.toJson()));
}

/// Get cached workout data if not expired
static Map<String, dynamic>? getCachedWorkoutData(String key) {
final cachedJson = _workoutCache?.get(key);
if (cachedJson == null) return null;

try {
final cacheEntry = CacheEntry.fromJson(json.decode(cachedJson));

// Check if cache is expired
if (cacheEntry.isExpired) {
_workoutCache?.delete(key);
return null;
}

return cacheEntry.data;
} catch (e) {
// Invalid cache entry, remove it
_workoutCache?.delete(key);
return null;
}
}

/// Cache user profile data
static Future<void> cacheUserData(String userId, Map<String, dynamic> userData) async {
final cacheEntry = CacheEntry(
data: userData,
timestamp: DateTime.now(),
ttl: const Duration(hours: 24),
);

await _userCache?.put(userId, json.encode(cacheEntry.toJson()));
}

/// Get cached user data
static Map<String, dynamic>? getCachedUserData(String userId) {
final cachedJson = _userCache?.get(userId);
if (cachedJson == null) return null;

try {
final cacheEntry = CacheEntry.fromJson(json.decode(cachedJson));

if (cacheEntry.isExpired) {
_userCache?.delete(userId);
return null;
}

return cacheEntry.data;
} catch (e) {
_userCache?.delete(userId);
return null;
}
}

/// Clear expired cache entries
static Future<void> clearExpiredCache() async {
await Future.wait([
_clearExpiredFromBox(_workoutCache),
_clearExpiredFromBox(_userCache),
_clearExpiredFromBox(_programCache),
]);
}

/// Clear all cache
static Future<void> clearAllCache() async {
await Future.wait([
_workoutCache?.clear() ?? Future.value(),
_userCache?.clear() ?? Future.value(),
_programCache?.clear() ?? Future.value(),
]);
}

/// Get cache statistics
static CacheStats getCacheStats() {
return CacheStats(
workoutCacheSize: _workoutCache?.length ?? 0,
userCacheSize: _userCache?.length ?? 0,
programCacheSize: _programCache?.length ?? 0,
totalEntries: (_workoutCache?.length ?? 0) +
(_userCache?.length ?? 0) +
(_programCache?.length ?? 0),
);
}

static Future<void> _clearExpiredFromBox(Box<String>? box) async {
if (box == null) return;

final keysToDelete = <String>[];

for (final key in box.keys) {
final cachedJson = box.get(key);
if (cachedJson != null) {
try {
final cacheEntry = CacheEntry.fromJson(json.decode(cachedJson));
if (cacheEntry.isExpired) {
keysToDelete.add(key);
}
} catch (e) {
// Invalid entry, mark for deletion
keysToDelete.add(key);
}
}
}

for (final key in keysToDelete) {
await box.delete(key);
}
}
}

class CacheEntry {
final Map<String, dynamic> data;
final DateTime timestamp;
final Duration ttl;

const CacheEntry({
required this.data,
required this.timestamp,
required this.ttl,
});

bool get isExpired => DateTime.now().isAfter(timestamp.add(ttl));

Map<String, dynamic> toJson() {
return {
'data': data,
'timestamp': timestamp.toIso8601String(),
'ttl': ttl.inMilliseconds,
};
}

factory CacheEntry.fromJson(Map<String, dynamic> json) {
return CacheEntry(
data: json['data'] as Map<String, dynamic>,
timestamp: DateTime.parse(json['timestamp']),
ttl: Duration(milliseconds: json['ttl']),
);
}
}

class CacheStats {
final int workoutCacheSize;
final int userCacheSize;
final int programCacheSize;
final int totalEntries;

const CacheStats({
required this.workoutCacheSize,
required this.userCacheSize,
required this.programCacheSize,
required this.totalEntries,
});
}

4. Event-Driven Architecture

Event Bus Implementation

// lib/services/event_bus.dart
import 'dart:async';

class EventBus {
static final EventBus _instance = EventBus._internal();
factory EventBus() => _instance;
EventBus._internal();

final Map<String, List<Function>> _listeners = {};
final StreamController<AppEvent> _eventController = StreamController<AppEvent>.broadcast();

Stream<AppEvent> get eventStream => _eventController.stream;

/// Emit an event with optional data
void emit(String eventType, [Map<String, dynamic>? data]) {
final event = AppEvent(
type: eventType,
data: data,
timestamp: DateTime.now(),
);

// Call direct listeners
final listeners = _listeners[eventType];
if (listeners != null) {
for (final listener in listeners) {
try {
listener(event);
} catch (e) {
print('Error in event listener for $eventType: $e');
}
}
}

// Add to stream
_eventController.add(event);
}

/// Subscribe to specific event type
void on(String eventType, Function(AppEvent) callback) {
_listeners.putIfAbsent(eventType, () => []);
_listeners[eventType]!.add(callback);
}

/// Unsubscribe from specific event type
void off(String eventType, Function callback) {
final listeners = _listeners[eventType];
if (listeners != null) {
listeners.remove(callback);
if (listeners.isEmpty) {
_listeners.remove(eventType);
}
}
}

/// Clear all listeners for event type
void removeAllListeners(String eventType) {
_listeners.remove(eventType);
}

/// Clear all listeners
void clearAll() {
_listeners.clear();
}

void dispose() {
clearAll();
_eventController.close();
}
}

class AppEvent {
final String type;
final Map<String, dynamic>? data;
final DateTime timestamp;

const AppEvent({
required this.type,
this.data,
required this.timestamp,
});

T? getData<T>(String key) {
return data?[key] as T?;
}

bool get hasData => data != null && data!.isNotEmpty;
}

// Common event types
class EventTypes {
// Authentication events
static const String authLogin = 'auth.login';
static const String authLogout = 'auth.logout';
static const String authUnauthenticated = 'auth.unauthenticated';

// Workout events
static const String workoutStarted = 'workout.started';
static const String workoutCompleted = 'workout.completed';
static const String workoutCancelled = 'workout.cancelled';
static const String workoutSetLogged = 'workout.setLogged';

// Progress events
static const String personalRecordAchieved = 'progress.personalRecord';
static const String goalCompleted = 'progress.goalCompleted';

// Sync events
static const String syncStarted = 'sync.started';
static const String syncCompleted = 'sync.completed';
static const String syncFailed = 'sync.failed';

// API events
static const String apiRateLimited = 'api.rateLimited';
static const String apiServerError = 'api.serverError';
}

Event Listeners

// lib/listeners/app_event_listener.dart
import 'package:flutter/foundation.dart';

class AppEventListener {
final NotificationService _notificationService;
final CacheService _cacheService;
final AnalyticsService _analyticsService;

AppEventListener({
required NotificationService notificationService,
required CacheService cacheService,
required AnalyticsService analyticsService,
}) : _notificationService = notificationService,
_cacheService = cacheService,
_analyticsService = analyticsService;

/// Initialize event listeners
void initialize() {
final eventBus = EventBus();

// Authentication event listeners
eventBus.on(EventTypes.authLogin, _handleAuthLogin);
eventBus.on(EventTypes.authLogout, _handleAuthLogout);
eventBus.on(EventTypes.authUnauthenticated, _handleAuthUnauthenticated);

// Workout event listeners
eventBus.on(EventTypes.workoutStarted, _handleWorkoutStarted);
eventBus.on(EventTypes.workoutCompleted, _handleWorkoutCompleted);
eventBus.on(EventTypes.personalRecordAchieved, _handlePersonalRecord);

// API event listeners
eventBus.on(EventTypes.apiRateLimited, _handleRateLimit);
eventBus.on(EventTypes.syncCompleted, _handleSyncCompleted);
}

void _handleAuthLogin(AppEvent event) {
if (kDebugMode) {
print('User logged in: ${event.getData<String>('userId')}');
}

// Track login analytics
_analyticsService.trackEvent('user_login', event.data);
}

void _handleAuthLogout(AppEvent event) {
// Clear all caches on logout
_cacheService.clearAllCache();

// Cancel any pending notifications
_notificationService.cancelAllNotifications();

if (kDebugMode) {
print('User logged out - caches cleared');
}
}

void _handleAuthUnauthenticated(AppEvent event) {
// Show session expired notification
_notificationService.showLocalNotification(
title: 'Session Expired',
body: 'Please log in again to continue',
data: {'action': 'navigate_to_login'},
);
}

void _handleWorkoutStarted(AppEvent event) {
final workoutId = event.getData<String>('workoutId');

if (kDebugMode) {
print('Workout started: $workoutId');
}

// Track workout analytics
_analyticsService.trackEvent('workout_started', event.data);

// Schedule workout completion reminder
_notificationService.scheduleNotification(
id: workoutId.hashCode,
title: 'Finish Your Workout',
body: 'Complete your workout to track your progress',
scheduledDate: DateTime.now().add(const Duration(hours: 2)),
);
}

void _handleWorkoutCompleted(AppEvent event) {
final workoutId = event.getData<String>('workoutId');
final summary = event.getData<Map<String, dynamic>>('summary');

// Cancel workout reminder
_notificationService.cancelNotification(workoutId.hashCode);

// Show completion notification
if (summary != null) {
final exercisesCompleted = summary['exercisesCompleted'] as int? ?? 0;
_notificationService.showLocalNotification(
title: 'Workout Complete! 🎉',
body: 'Great job! You completed $exercisesCompleted exercises.',
data: {
'action': 'view_workout_summary',
'workoutId': workoutId,
},
);
}

// Track completion analytics
_analyticsService.trackEvent('workout_completed', {
...event.data ?? {},
'completion_method': 'manual',
});
}

void _handlePersonalRecord(AppEvent event) {
final exerciseName = event.getData<String>('exerciseName');
final recordType = event.getData<String>('recordType');
final value = event.getData<dynamic>('value');

// Show personal record achievement notification
_notificationService.showLocalNotification(
title: 'Personal Record! 🏆',
body: 'New $recordType record for $exerciseName: $value',
data: {
'action': 'view_progress',
'exerciseId': event.getData<String>('exerciseId'),
},
);

// Track achievement analytics
_analyticsService.trackEvent('personal_record_achieved', event.data);
}

void _handleRateLimit(AppEvent event) {
final operation = event.getData<String>('operation');

if (kDebugMode) {
print('API rate limited for operation: $operation');
}

// Show user-friendly message
_notificationService.showLocalNotification(
title: 'Slow Down',
body: 'Making requests too quickly. Please wait a moment.',
);
}

void _handleSyncCompleted(AppEvent event) {
final syncType = event.getData<String>('syncType');
final itemsSync = event.getData<int>('itemsSync') ?? 0;

if (kDebugMode) {
print('Sync completed: $syncType ($itemsSync items)');
}

// Clear expired cache after successful sync
_cacheService.clearExpiredCache();
}
}

5. Navigation Architecture

App Router Configuration

// lib/router/app_router.dart
import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';

class AppRouter {
static final GoRouter _router = GoRouter(
initialLocation: '/splash',
routes: [
// Authentication Routes
GoRoute(
path: '/splash',
name: 'splash',
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/register',
name: 'register',
builder: (context, state) => const RegisterScreen(),
),

// Main App Shell
ShellRoute(
builder: (context, state, child) => MainAppShell(child: child),
routes: [
// Home Tab
GoRoute(
path: '/home',
name: 'home',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: '/workout/:workoutId',
name: 'workoutDetails',
builder: (context, state) => WorkoutDetailsScreen(
workoutId: state.params['workoutId']!,
),
),
],
),

// Programs Tab
GoRoute(
path: '/programs',
name: 'programs',
builder: (context, state) => const ProgramsScreen(),
routes: [
GoRoute(
path: '/:programId',
name: 'programDetails',
builder: (context, state) => ProgramDetailsScreen(
programId: state.params['programId']!,
),
routes: [
GoRoute(
path: '/workout/:workoutId',
name: 'programWorkoutDetails',
builder: (context, state) => WorkoutDetailsScreen(
workoutId: state.params['workoutId']!,
programId: state.params['programId'],
),
),
],
),
],
),

// Active Workout
GoRoute(
path: '/workout-session',
name: 'workoutSession',
builder: (context, state) => const ActiveWorkoutScreen(),
),

// Progress Tab
GoRoute(
path: '/progress',
name: 'progress',
builder: (context, state) => const ProgressScreen(),
routes: [
GoRoute(
path: '/analytics',
name: 'analytics',
builder: (context, state) => const AnalyticsScreen(),
),
GoRoute(
path: '/exercise/:exerciseId',
name: 'exerciseProgress',
builder: (context, state) => ExerciseProgressScreen(
exerciseId: state.params['exerciseId']!,
),
),
],
),

// Profile Tab
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileScreen(),
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
],
),
],
redirect: (context, state) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final isLoggedIn = authProvider.isAuthenticated;
final isLoggingIn = state.location == '/login' ||
state.location == '/register' ||
state.location == '/splash';

// If not logged in and not on auth screens, redirect to login
if (!isLoggedIn && !isLoggingIn) {
return '/login';
}

// If logged in and on auth screens, redirect to home
if (isLoggedIn && isLoggingIn) {
return '/home';
}

return null; // No redirect needed
},
errorBuilder: (context, state) => ErrorScreen(error: state.error),
);

static GoRouter get router => _router;
}

// Main app shell with bottom navigation
class MainAppShell extends StatelessWidget {
final Widget child;

const MainAppShell({Key? key, required this.child}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: const BottomNavigation(),
);
}
}

class BottomNavigation extends StatelessWidget {
const BottomNavigation({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
final currentLocation = GoRouter.of(context).location;

int getCurrentIndex() {
if (currentLocation.startsWith('/home')) return 0;
if (currentLocation.startsWith('/programs')) return 1;
if (currentLocation.startsWith('/progress')) return 2;
if (currentLocation.startsWith('/profile')) return 3;
return 0;
}

return Consumer<WorkoutProvider>(
builder: (context, workoutProvider, child) {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: getCurrentIndex(),
onTap: (index) {
switch (index) {
case 0:
context.goNamed('home');
break;
case 1:
context.goNamed('programs');
break;
case 2:
context.goNamed('progress');
break;
case 3:
context.goNamed('profile');
break;
}
},
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
),
const BottomNavigationBarItem(
icon: Icon(Icons.fitness_center_outlined),
activeIcon: Icon(Icons.fitness_center),
label: 'Programs',
),
const BottomNavigationBarItem(
icon: Icon(Icons.trending_up_outlined),
activeIcon: Icon(Icons.trending_up),
label: 'Progress',
),
const BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profile',
),
],
// Show workout indicator if active workout
badges: workoutProvider.hasActiveWorkout
? const [null, null, null, null]
: null,
);
},
);
}
}

Summary

This client architecture guide provides:

  1. Service Layer Architecture: Base service pattern with error handling, logging, and pagination support
  2. State Management: Provider pattern for centralized state management with proper separation of concerns
  3. Data Layer: Caching service with TTL support and offline capabilities
  4. Event-Driven Architecture: Event bus for loose coupling and cross-component communication
  5. Navigation Architecture: Go Router configuration with proper route management and authentication guards

⚠️ Critical Architecture Principles

  • Thin Client Enforcement: All business logic remains server-side; clients only handle UI state and data presentation
  • Service Layer Abstraction: GraphQL operations wrapped in typed service methods with comprehensive error handling
  • Event-Driven Communication: Loose coupling between components through centralized event system
  • Proper State Management: Clear separation between local UI state and server-synchronized data
  • Offline-First Design: Caching layer enables graceful offline operation with sync when online

Next Steps

  1. Implement Base Service: Start with the base service pattern for all API communication
  2. Add State Management: Implement provider pattern for your specific use cases
  3. Configure Caching: Set up offline support with appropriate cache TTL values
  4. Set Up Events: Implement event bus for cross-component communication
  5. Configure Navigation: Set up proper routing with authentication guards

Next Guide: Continue with the Flutter Integration Guide for platform-specific implementation patterns.