Flutter Integration Guide
This comprehensive guide covers Flutter-specific integration patterns for OpenLift, including state management, offline support, platform integration, and UI patterns optimized for fitness tracking applications.
Table of Contents
- Project Setup
- GraphQL Integration
- State Management
- Authentication Flow
- Offline & Caching
- UI Patterns
- Platform Integration
- Performance Optimization
- Testing Strategies
Project Setup
Dependencies
# pubspec.yaml
name: openlift_client
description: OpenLift Fitness Tracking Application
dependencies:
flutter:
sdk: flutter
# GraphQL & Network
graphql_flutter: ^5.1.2
http: ^1.1.0
dio: ^5.3.2
# State Management
provider: ^6.1.1
riverpod: ^2.4.6 # Alternative to Provider
# Storage & Caching
shared_preferences: ^2.2.2
flutter_secure_storage: ^9.0.0
hive: ^2.2.3
hive_flutter: ^1.1.0
# Platform Integration
health: ^10.0.0
permission_handler: ^11.0.1
device_info_plus: ^9.1.0
# UI & Charts
fl_chart: ^0.64.0
cached_network_image: ^3.3.0
shimmer: ^3.0.0
lottie: ^2.7.0
# Utilities
intl: ^0.18.1
uuid: ^4.1.0
connectivity_plus: ^5.0.1
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.4.2
build_runner: ^2.4.7
hive_generator: ^2.0.1
Project Structure
lib/
├── main.dart
├── app.dart
├── config/
│ ├── app_config.dart
│ └── theme_config.dart
├── core/
│ ├── graphql/
│ │ ├── client.dart
│ │ └── mutations.dart
│ ├── storage/
│ │ ├── secure_storage.dart
│ │ └── cache_manager.dart
│ └── utils/
│ ├── date_utils.dart
│ └── number_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── providers/
│ ├── workout/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── providers/
│ └── profile/
├── shared/
│ ├── widgets/
│ ├── models/
│ └── constants/
└── l10n/
GraphQL Integration
GraphQL Client Setup
// lib/core/graphql/client.dart
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class GraphQLClientManager {
static GraphQLClient? _client;
static const _storage = FlutterSecureStorage();
static Future<GraphQLClient> getClient() async {
if (_client != null) return _client!;
final httpLink = HttpLink(
AppConfig.graphqlEndpoint,
defaultHeaders: {
'Content-Type': 'application/json',
},
);
// Authentication Link
final authLink = AuthLink(
getToken: () async {
final token = await _storage.read(key: StorageKeys.accessToken);
return token != null ? 'Bearer $token' : null;
},
);
// Error Link for handling authentication errors
final errorLink = ErrorLink(
errorHandler: (ErrorLinkException exception) async {
// Handle token refresh
if (_shouldRefreshToken(exception)) {
await _refreshToken();
}
// Handle network errors
if (exception.linkException is NetworkException) {
_handleNetworkError(exception.linkException as NetworkException);
}
},
);
// Logging Link for development
final loggingLink = LoggingLink();
final link = Link.from([
if (AppConfig.isDevelopment) loggingLink,
errorLink,
authLink,
httpLink,
]);
_client = GraphQLClient(
link: link,
cache: GraphQLCache(
store: HiveStore(),
),
defaultPolicies: DefaultPolicies(
query: Policies(
cachePolicy: CachePolicy.cacheFirst,
fetchPolicy: FetchPolicy.cacheFirst,
),
mutate: Policies(
cachePolicy: CachePolicy.networkOnly,
),
),
);
return _client!;
}
static bool _shouldRefreshToken(ErrorLinkException exception) {
return exception.response?.errors?.any(
(error) => error.extensions?['code'] == 'UNAUTHENTICATED'
) == true;
}
static Future<void> _refreshToken() async {
// Implementation in Authentication section
}
static void _handleNetworkError(NetworkException exception) {
// Show network error UI
GlobalSnackbar.showError('Network connection failed');
}
}
GraphQL Operations Helper
// lib/core/graphql/operations.dart
class GraphQLOperations {
static final _client = GraphQLClientManager.getClient();
// Generic query method with error handling
static Future<T> query<T>({
required String query,
required Map<String, dynamic> variables,
required T Function(Map<String, dynamic>) parser,
CachePolicy? cachePolicy,
}) async {
try {
final client = await _client;
final result = await client.query(
QueryOptions(
document: gql(query),
variables: variables,
cachePolicy: cachePolicy ?? CachePolicy.cacheFirst,
),
);
if (result.hasException) {
throw OpenLiftApiException.fromGraphQLException(result.exception!);
}
return parser(result.data!);
} catch (e) {
if (e is OpenLiftApiException) rethrow;
throw OpenLiftApiException.unknown(e.toString());
}
}
// Generic mutation method
static Future<T> mutate<T>({
required String mutation,
required Map<String, dynamic> variables,
required T Function(Map<String, dynamic>) parser,
}) async {
try {
final client = await _client;
final result = await client.mutate(
MutationOptions(
document: gql(mutation),
variables: variables,
),
);
if (result.hasException) {
throw OpenLiftApiException.fromGraphQLException(result.exception!);
}
return parser(result.data!);
} catch (e) {
if (e is OpenLiftApiException) rethrow;
throw OpenLiftApiException.unknown(e.toString());
}
}
}
State Management with Provider
App-Level Provider Setup
// lib/main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
await HiveAdapters.registerAdapters();
runApp(
MultiProvider(
providers: [
// Core providers
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => ConnectivityProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
// Feature providers
ChangeNotifierProvider(create: (_) => WorkoutProvider()),
ChangeNotifierProvider(create: (_) => ExerciseProvider()),
ChangeNotifierProvider(create: (_) => ProgressProvider()),
ChangeNotifierProvider(create: (_) => AnalyticsProvider()),
// Proxy providers that depend on auth
ChangeNotifierProxyProvider<AuthProvider, UserProfileProvider>(
create: (_) => UserProfileProvider(),
update: (_, auth, previous) {
return previous?..updateAuthUser(auth.user) ?? UserProfileProvider();
},
),
],
child: const OpenLiftApp(),
),
);
}
Authentication Provider
// lib/features/auth/providers/auth_provider.dart
class AuthProvider extends ChangeNotifier {
User? _user;
bool _isAuthenticated = false;
bool _isLoading = false;
String? _error;
User? get user => _user;
bool get isAuthenticated => _isAuthenticated;
bool get isLoading => _isLoading;
String? get error => _error;
final AuthService _authService = AuthService();
final SecureStorageService _storageService = SecureStorageService();
AuthProvider() {
_checkAuthStatus();
}
Future<void> _checkAuthStatus() async {
_setLoading(true);
try {
final token = await _storageService.getAccessToken();
if (token != null && !_isTokenExpired(token)) {
// Token is valid, get user profile
_user = await _authService.getCurrentUser();
_isAuthenticated = true;
}
} catch (e) {
// Clear invalid tokens
await _storageService.clearTokens();
_isAuthenticated = false;
_user = null;
}
_setLoading(false);
}
Future<bool> login(String email, String password) async {
_setLoading(true);
_setError(null);
try {
final authResult = await _authService.login(email, password);
if (authResult.success) {
_user = authResult.user;
_isAuthenticated = true;
// Store tokens securely
await _storageService.storeTokens(
authResult.accessToken!,
authResult.refreshToken!,
);
_setLoading(false);
return true;
} else {
_setError(authResult.error);
_setLoading(false);
return false;
}
} catch (e) {
_setError(e.toString());
_setLoading(false);
return false;
}
}
Future<void> logout() async {
_setLoading(true);
try {
await _authService.logout();
await _storageService.clearTokens();
_user = null;
_isAuthenticated = false;
// Clear all cached data
final client = await GraphQLClientManager.getClient();
await client.cache.reset();
} catch (e) {
// Even if logout fails on server, clear local state
_user = null;
_isAuthenticated = false;
await _storageService.clearTokens();
}
_setLoading(false);
}
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String? error) {
_error = error;
notifyListeners();
}
}
Workout Provider
// lib/features/workout/providers/workout_provider.dart
class WorkoutProvider extends ChangeNotifier {
WorkoutSession? _currentWorkout;
List<WorkoutHistoryEntry> _workoutHistory = [];
bool _isLoading = false;
Timer? _workoutTimer;
int _elapsedSeconds = 0;
WorkoutSession? get currentWorkout => _currentWorkout;
List<WorkoutHistoryEntry> get workoutHistory => _workoutHistory;
bool get isLoading => _isLoading;
bool get hasActiveWorkout => _currentWorkout != null;
String get elapsedTime => _formatDuration(_elapsedSeconds);
final WorkoutService _workoutService = WorkoutService();
// Start a new workout
Future<bool> startWorkout({
required String programWorkoutTemplateId,
String? programUserInstanceId,
int? weekNumber,
}) async {
if (_currentWorkout != null) {
throw Exception('A workout is already in progress');
}
_setLoading(true);
try {
_currentWorkout = await _workoutService.startWorkout(
programWorkoutTemplateId: programWorkoutTemplateId,
programUserInstanceId: programUserInstanceId,
weekNumber: weekNumber,
);
_startTimer();
_setLoading(false);
return true;
} catch (e) {
_setLoading(false);
throw Exception('Failed to start workout: $e');
}
}
// Log a completed set
Future<void> logSet({
required String exerciseId,
required int setNumber,
required double weight,
required int reps,
required int rpe,
String? notes,
}) async {
if (_currentWorkout == null) {
throw Exception('No active workout');
}
try {
final setData = CompletedSet(
setNumber: setNumber,
weight: weight,
reps: reps,
rpe: rpe,
restSeconds: 0, // Will be calculated
notes: notes,
);
await _workoutService.logExerciseSet(
workoutId: _currentWorkout!.id,
exerciseId: exerciseId,
setData: setData,
);
// Update local workout state
_updateWorkoutWithNewSet(exerciseId, setData);
} catch (e) {
throw Exception('Failed to log set: $e');
}
}
// Complete workout
Future<void> completeWorkout({
required WorkoutFeedback feedback,
PostWorkoutStretching? stretching,
}) async {
if (_currentWorkout == null) {
throw Exception('No active workout to complete');
}
_setLoading(true);
try {
final completedWorkout = await _workoutService.completeWorkout(
workoutId: _currentWorkout!.id,
feedback: feedback,
stretching: stretching,
);
// Add to history and clear current workout
_workoutHistory.insert(0, completedWorkout);
_currentWorkout = null;
_stopTimer();
_setLoading(false);
} catch (e) {
_setLoading(false);
throw Exception('Failed to complete workout: $e');
}
}
// Load workout history
Future<void> loadWorkoutHistory({
int limit = 20,
int offset = 0,
DateTime? startDate,
DateTime? endDate,
}) async {
_setLoading(true);
try {
final history = await _workoutService.getWorkoutHistory(
limit: limit,
offset: offset,
startDate: startDate,
endDate: endDate,
);
if (offset == 0) {
_workoutHistory = history;
} else {
_workoutHistory.addAll(history);
}
_setLoading(false);
} catch (e) {
_setLoading(false);
throw Exception('Failed to load workout history: $e');
}
}
void _startTimer() {
_elapsedSeconds = 0;
_workoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
_elapsedSeconds++;
notifyListeners();
});
}
void _stopTimer() {
_workoutTimer?.cancel();
_workoutTimer = null;
_elapsedSeconds = 0;
}
void _updateWorkoutWithNewSet(String exerciseId, CompletedSet setData) {
// Update local workout state for immediate UI feedback
// This is UI state management, not business logic
if (_currentWorkout != null) {
// Find and update the exercise in the current workout
// Implementation details...
notifyListeners();
}
}
String _formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final remainingSeconds = seconds % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
}
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
@override
void dispose() {
_stopTimer();
super.dispose();
}
}
Authentication Flow
Login Screen
// lib/features/auth/presentation/screens/login_screen.dart
class LoginScreen extends StatefulWidget {
@override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Consumer<AuthProvider>(
builder: (context, authProvider, child) {
if (authProvider.isAuthenticated) {
// Navigate to main app
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/home');
});
}
return Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
const FlutterLogo(size: 80),
const SizedBox(height: 32),
// Title
Text(
'Welcome to OpenLift',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
// Email field
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: const OutlineInputBorder(),
),
obscureText: _obscurePassword,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
),
// Error display
if (authProvider.error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
authProvider.error!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
],
const SizedBox(height: 24),
// Login button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: authProvider.isLoading ? null : _login,
child: authProvider.isLoading
? const CircularProgressIndicator()
: const Text('Login'),
),
),
const SizedBox(height: 16),
// Register link
TextButton(
onPressed: () {
Navigator.of(context).pushNamed('/register');
},
child: const Text('Don\'t have an account? Sign up'),
),
],
),
),
);
},
),
),
);
}
Future<void> _login() async {
if (_formKey.currentState?.validate() != true) return;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final success = await authProvider.login(
_emailController.text.trim(),
_passwordController.text,
);
if (success) {
Navigator.of(context).pushReplacementNamed('/home');
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
Offline & Caching Strategy
Cache Manager
// lib/core/storage/cache_manager.dart
class CacheManager {
static const String _exercisesCacheKey = 'cached_exercises';
static const String _muscleGroupsCacheKey = 'cached_muscle_groups';
static const String _workoutHistoryCacheKey = 'cached_workout_history';
// Cache exercises for offline use
static Future<void> cacheExercises(List<Exercise> exercises) async {
final box = await Hive.openBox('exercise_cache');
final exerciseMap = <String, Map<String, dynamic>>{};
for (final exercise in exercises) {
exerciseMap[exercise.id] = exercise.toJson();
}
await box.put(_exercisesCacheKey, exerciseMap);
await box.put('${_exercisesCacheKey}_timestamp', DateTime.now().millisecondsSinceEpoch);
}
// Get cached exercises
static Future<List<Exercise>> getCachedExercises() async {
final box = await Hive.openBox('exercise_cache');
final exerciseMap = box.get(_exercisesCacheKey) as Map<String, dynamic>?;
if (exerciseMap == null) return [];
return exerciseMap.values
.map((json) => Exercise.fromJson(json))
.toList();
}
// Check if cache is fresh
static Future<bool> isCacheFresh(String cacheKey, Duration maxAge) async {
final box = await Hive.openBox('exercise_cache');
final timestamp = box.get('${cacheKey}_timestamp') as int?;
if (timestamp == null) return false;
final cacheDate = DateTime.fromMillisecondsSinceEpoch(timestamp);
return DateTime.now().difference(cacheDate) < maxAge;
}
// Cache workout history
static Future<void> cacheWorkoutHistory(List<WorkoutHistoryEntry> history) async {
final box = await Hive.openBox('workout_cache');
final historyMap = <String, Map<String, dynamic>>{};
for (final workout in history) {
historyMap[workout.id] = workout.toJson();
}
await box.put(_workoutHistoryCacheKey, historyMap);
await box.put('${_workoutHistoryCacheKey}_timestamp', DateTime.now().millisecondsSinceEpoch);
}
// Sync cached data when back online
static Future<void> syncCachedData() async {
final connectivityProvider = Get.find<ConnectivityProvider>();
if (!connectivityProvider.isConnected) return;
try {
// Sync any pending workout submissions
await _syncPendingWorkouts();
// Update cached exercise data
await _updateExerciseCache();
// Sync user profile changes
await _syncUserProfileChanges();
} catch (e) {
print('Sync failed: $e');
// Handle sync failures gracefully
}
}
static Future<void> _syncPendingWorkouts() async {
final box = await Hive.openBox('pending_workouts');
final pendingWorkouts = box.values.toList();
for (final workoutData in pendingWorkouts) {
try {
// Submit workout to server
await WorkoutService().submitCachedWorkout(workoutData);
// Remove from pending cache
await box.delete(workoutData['id']);
} catch (e) {
// Keep in pending cache for next sync attempt
print('Failed to sync workout ${workoutData['id']}: $e');
}
}
}
}
Connectivity Provider
// lib/providers/connectivity_provider.dart
class ConnectivityProvider extends ChangeNotifier {
bool _isConnected = true;
ConnectivityResult _connectionType = ConnectivityResult.wifi;
bool get isConnected => _isConnected;
bool get isOnMobileData => _connectionType == ConnectivityResult.mobile;
bool get isOnWifi => _connectionType == ConnectivityResult.wifi;
ConnectivityProvider() {
_initConnectivity();
}
Future<void> _initConnectivity() async {
final connectivity = Connectivity();
// Check initial connectivity
final result = await connectivity.checkConnectivity();
_updateConnectionStatus(result);
// Listen for changes
connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
}
void _updateConnectionStatus(ConnectivityResult result) {
final wasConnected = _isConnected;
_isConnected = result != ConnectivityResult.none;
_connectionType = result;
notifyListeners();
// Trigger sync when back online
if (!wasConnected && _isConnected) {
CacheManager.syncCachedData();
}
}
}
UI Patterns
Workout Timer Widget
// lib/shared/widgets/workout_timer_widget.dart
class WorkoutTimerWidget extends StatefulWidget {
final VoidCallback? onStart;
final VoidCallback? onPause;
final VoidCallback? onStop;
@override
_WorkoutTimerWidgetState createState() => _WorkoutTimerWidgetState();
}
class _WorkoutTimerWidgetState extends State<WorkoutTimerWidget>
with TickerProviderStateMixin {
late AnimationController _pulseController;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
)..repeat(reverse: true);
}
@override
Widget build(BuildContext context) {
return Consumer<WorkoutProvider>(
builder: (context, workoutProvider, child) {
final hasActiveWorkout = workoutProvider.hasActiveWorkout;
final elapsedTime = workoutProvider.elapsedTime;
return Card(
elevation: hasActiveWorkout ? 8 : 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Timer display
AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
return Transform.scale(
scale: hasActiveWorkout ? 1.0 + (_pulseController.value * 0.05) : 1.0,
child: Text(
elapsedTime,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: hasActiveWorkout ? Colors.red : null,
fontWeight: FontWeight.bold,
),
),
);
},
),
const SizedBox(height: 16),
// Status indicator
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
hasActiveWorkout ? Icons.fitness_center : Icons.timer,
color: hasActiveWorkout ? Colors.green : Colors.grey,
),
const SizedBox(width: 8),
Text(
hasActiveWorkout ? 'Workout in Progress' : 'Ready to Start',
style: TextStyle(
color: hasActiveWorkout ? Colors.green : Colors.grey,
fontWeight: FontWeight.w500,
),
),
],
),
if (hasActiveWorkout) ...[
const SizedBox(height: 16),
// Workout controls
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: widget.onPause,
icon: const Icon(Icons.pause),
label: const Text('Pause'),
),
ElevatedButton.icon(
onPressed: widget.onStop,
icon: const Icon(Icons.stop),
label: const Text('Finish'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
),
],
),
],
],
),
),
);
},
);
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
}
Exercise Set Logger Widget
// lib/features/workout/presentation/widgets/set_logger_widget.dart
class SetLoggerWidget extends StatefulWidget {
final Exercise exercise;
final int setNumber;
final CompletedSet? previousSet;
final Function(CompletedSet) onSetCompleted;
@override
_SetLoggerWidgetState createState() => _SetLoggerWidgetState();
}
class _SetLoggerWidgetState extends State<SetLoggerWidget> {
final _weightController = TextEditingController();
final _repsController = TextEditingController();
final _notesController = TextEditingController();
int _selectedRpe = 7;
@override
void initState() {
super.initState();
// Pre-fill with previous set data if available
if (widget.previousSet != null) {
_weightController.text = widget.previousSet!.weight.toString();
_repsController.text = widget.previousSet!.reps.toString();
_selectedRpe = widget.previousSet!.rpe;
}
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Set header
Row(
children: [
Text(
'Set ${widget.setNumber}',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
if (widget.previousSet != null)
Text(
'Previous: ${widget.previousSet!.weight}kg × ${widget.previousSet!.reps}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 16),
// Weight and reps input
Row(
children: [
Expanded(
child: TextFormField(
controller: _weightController,
decoration: const InputDecoration(
labelText: 'Weight (kg)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _repsController,
decoration: const InputDecoration(
labelText: 'Reps',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
),
],
),
const SizedBox(height: 16),
// RPE selector
Text(
'RPE (Rate of Perceived Exertion)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(10, (index) {
final rpe = index + 1;
return GestureDetector(
onTap: () {
setState(() {
_selectedRpe = rpe;
});
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: _selectedRpe == rpe
? Theme.of(context).primaryColor
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
rpe.toString(),
style: TextStyle(
color: _selectedRpe == rpe ? Colors.white : Colors.black,
fontWeight: FontWeight.bold,
),
),
),
),
);
}),
),
const SizedBox(height: 16),
// Notes (optional)
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes (optional)',
border: OutlineInputBorder(),
),
maxLines: 2,
),
const SizedBox(height: 16),
// Log set button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _canLogSet() ? _logSet : null,
child: const Text('Log Set'),
),
),
],
),
),
);
}
bool _canLogSet() {
return _weightController.text.isNotEmpty &&
_repsController.text.isNotEmpty;
}
void _logSet() {
final weight = double.tryParse(_weightController.text);
final reps = int.tryParse(_repsController.text);
if (weight == null || reps == null || reps <= 0 || weight <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter valid weight and reps')),
);
return;
}
final completedSet = CompletedSet(
setNumber: widget.setNumber,
weight: weight,
reps: reps,
rpe: _selectedRpe,
restSeconds: 0, // Will be calculated by the server
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
);
widget.onSetCompleted(completedSet);
// Show success feedback
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Set logged successfully!'),
duration: Duration(seconds: 1),
),
);
}
@override
void dispose() {
_weightController.dispose();
_repsController.dispose();
_notesController.dispose();
super.dispose();
}
}
Platform Integration
HealthKit Integration (iOS)
// lib/services/health_service.dart
class HealthService {
static final Health _health = Health();
static Future<bool> requestPermissions() async {
final types = [
HealthDataType.WORKOUT,
HealthDataType.ACTIVE_ENERGY_BURNED,
HealthDataType.HEART_RATE,
];
return await _health.requestAuthorization(types);
}
static Future<void> writeWorkout(WorkoutHistoryEntry workout) async {
final hasPermissions = await _health.hasPermissions([HealthDataType.WORKOUT]);
if (!hasPermissions) return;
await _health.writeWorkoutData(
activityType: HealthWorkoutActivityType.STRENGTH_TRAINING,
start: workout.startTime,
end: workout.endTime ?? DateTime.now(),
totalEnergyBurned: workout.estimatedCalories?.toInt(),
totalEnergyBurnedUnit: HealthDataUnit.KILOCALORIE,
);
}
static Future<List<HealthDataPoint>> readHeartRateData({
required DateTime start,
required DateTime end,
}) async {
final hasPermissions = await _health.hasPermissions([HealthDataType.HEART_RATE]);
if (!hasPermissions) return [];
return await _health.getHealthDataFromTypes(
start,
end,
[HealthDataType.HEART_RATE],
);
}
}
Performance Optimization
Image Caching & Loading
// lib/shared/widgets/cached_exercise_image.dart
class CachedExerciseImage extends StatelessWidget {
final String? imageUrl;
final double? width;
final double? height;
final BoxFit fit;
const CachedExerciseImage({
Key? key,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (imageUrl == null) {
return _buildPlaceholder();
}
return CachedNetworkImage(
imageUrl: imageUrl!,
width: width,
height: height,
fit: fit,
placeholder: (context, url) => _buildShimmer(),
errorWidget: (context, url, error) => _buildError(),
cacheManager: CustomCacheManager.instance,
fadeInDuration: const Duration(milliseconds: 200),
fadeOutDuration: const Duration(milliseconds: 200),
);
}
Widget _buildPlaceholder() {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.fitness_center,
color: Colors.grey,
),
);
}
Widget _buildShimmer() {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
);
}
Widget _buildError() {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.error,
color: Colors.red.shade400,
),
);
}
}
Custom Cache Manager
// lib/core/cache/custom_cache_manager.dart
class CustomCacheManager extends CacheManager {
static const key = 'openLiftCache';
static CustomCacheManager? _instance;
factory CustomCacheManager() {
return _instance ??= CustomCacheManager._();
}
CustomCacheManager._()
: super(
Config(
key,
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 200,
repo: JsonCacheInfoRepository(databaseName: key),
fileSystem: IOFileSystem(key),
fileService: HttpFileService(),
),
);
static CustomCacheManager get instance => CustomCacheManager();
}
Testing Strategies
Widget Tests
// test/widgets/set_logger_widget_test.dart
void main() {
group('SetLoggerWidget', () {
testWidgets('should display exercise information', (WidgetTester tester) async {
final exercise = Exercise(
id: '1',
name: 'Bench Press',
slug: 'bench-press',
tips: ['Keep your back flat'],
muscleGroups: [],
numPrimaryItems: 1,
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SetLoggerWidget(
exercise: exercise,
setNumber: 1,
onSetCompleted: (set) {},
),
),
),
);
expect(find.text('Set 1'), findsOneWidget);
expect(find.text('Weight (kg)'), findsOneWidget);
expect(find.text('Reps'), findsOneWidget);
});
testWidgets('should validate input before logging set', (WidgetTester tester) async {
final exercise = Exercise(
id: '1',
name: 'Bench Press',
slug: 'bench-press',
tips: [],
muscleGroups: [],
numPrimaryItems: 1,
);
bool setLogged = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SetLoggerWidget(
exercise: exercise,
setNumber: 1,
onSetCompleted: (set) {
setLogged = true;
},
),
),
),
);
// Tap log button without entering data
await tester.tap(find.text('Log Set'));
await tester.pump();
expect(setLogged, false);
});
});
}
Integration Tests
// test/integration/auth_flow_test.dart
void main() {
group('Authentication Flow', () {
testWidgets('should complete login flow', (WidgetTester tester) async {
await tester.pumpWidget(MyApp());
// Navigate to login screen
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
await tester.enterText(find.byKey(Key('password_field')), 'testpassword');
// Submit login
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Should navigate to home screen
expect(find.text('Welcome'), findsOneWidget);
});
});
}
Deployment Configuration
Environment Configuration
// lib/config/app_config.dart
class AppConfig {
static const String _environment = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'development',
);
static bool get isDevelopment => _environment == 'development';
static bool get isStaging => _environment == 'staging';
static bool get isProduction => _environment == 'production';
static String get appName {
switch (_environment) {
case 'production':
return 'OpenLift';
case 'staging':
return 'OpenLift Staging';
default:
return 'OpenLift Dev';
}
}
static String get graphqlEndpoint {
switch (_environment) {
case 'production':
return 'https://api.openlift.app/graphql';
case 'staging':
return 'https://staging-api.openlift.app/graphql';
default:
return 'http://localhost:8000/graphql';
}
}
static Duration get cacheTimeout {
return isProduction
? const Duration(hours: 1)
: const Duration(minutes: 5);
}
}
This comprehensive Flutter integration guide provides all the essential patterns for building a robust, scalable OpenLift mobile application. Remember to always follow the thin client architecture principles and leverage the server-side intelligence while focusing on delivering an exceptional user experience.