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:
- Service Layer Architecture: Base service pattern with error handling, logging, and pagination support
- State Management: Provider pattern for centralized state management with proper separation of concerns
- Data Layer: Caching service with TTL support and offline capabilities
- Event-Driven Architecture: Event bus for loose coupling and cross-component communication
- 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
- Implement Base Service: Start with the base service pattern for all API communication
- Add State Management: Implement provider pattern for your specific use cases
- Configure Caching: Set up offline support with appropriate cache TTL values
- Set Up Events: Implement event bus for cross-component communication
- Configure Navigation: Set up proper routing with authentication guards
Next Guide: Continue with the Flutter Integration Guide for platform-specific implementation patterns.