A production-ready Flutter project template implementing Clean Architecture principles with Riverpod for state management. This template provides a solid foundation for building scalable, maintainable, and testable Flutter applications.
- Clean Architecture β Domain, data, and presentation layers separation
- Riverpod State Management β Powerful, testable state management
- Multi-language Support β Full internationalization with language switching
- Advanced Caching β Memory and disk caching with type-safety
- Biometric Authentication β Secure fingerprint and face recognition
- Feature Flags β A/B testing and staged rollouts
- Analytics Integration β Flexible event tracking
- Push Notifications β Deep linking and background handling
- Accessibility β Screen reader and dynamic text support
- Offline-First β Work seamlessly with or without connection
- CI/CD Ready β Automated workflows with GitHub Actions
- Architecture Guide - Project structure and principles
- Utility Tools - CLI tools for development
- Feature Documentation - Core features explained
- Code Examples - Usage examples
- Online Documentation - Complete reference
lib/
βββ core/ # Core shared functionality
βββ features/ # Feature modules
β βββ feature_name/ # Individual feature
β βββ data/ # Data layer (repositories, sources)
β βββ domain/ # Domain layer (entities, use cases)
β βββ presentation/ # UI layer (screens, providers)
βββ examples/ # Example implementations
βββ main.dart # Application entry point
# Clone the repository
git clone https://github.com/ssoad/flutter_riverpod_clean_architecture.git
# Navigate to the project directory
cd flutter_riverpod_clean_architecture
# Install dependencies
flutter pub get
# Run the app
flutter run
Detailed Getting Started Guide
This template provides two approaches to creating new features: automated generation with our powerful CLI tools or manual setup following Clean Architecture principles.
The fastest way to create a new feature is using our built-in feature generator:
./generate_feature.sh --name user_profile
This will automatically:
- Create the complete folder structure following Clean Architecture
- Generate data, domain, and presentation layer templates
- Add Riverpod providers with proper dependency injection
- Create test file templates for each component
- Add basic documentation for the feature
# Basic usage - creates a complete feature with all layers
./generate_feature.sh --name user_profile
# Create a feature without UI (for background services)
./generate_feature.sh --name analytics_service --no-ui
# Create a feature without repository pattern (simplified structure)
./generate_feature.sh --name theme_switcher --no-repository
# Create a UI-only feature (for shared components)
./generate_feature.sh --name custom_button --ui-only
# Create a service-only feature (for utility services)
./generate_feature.sh --name logger --service-only
# Create data-only feature without tests
./generate_feature.sh --name local_storage --no-ui --no-tests
# Minimal feature without UI, tests or docs (for utilities)
./generate_feature.sh --name formatter --no-ui --no-tests --no-docs
# See all available options
./generate_feature.sh --help
The feature generator creates different structures based on the options you choose:
lib/features/feature_name/
βββ data/
β βββ datasources/
β β βββ feature_name_remote_datasource.dart
β β βββ feature_name_local_datasource.dart
β βββ models/
β β βββ feature_name_model.dart
β βββ repositories/
β βββ feature_name_repository_impl.dart
βββ domain/
β βββ entities/
β β βββ feature_name_entity.dart
β βββ repositories/
β β βββ feature_name_repository.dart
β βββ usecases/
β βββ get_all_feature_names.dart
β βββ get_feature_name_by_id.dart
βββ presentation/ (optional with --no-ui flag)
β βββ providers/
β β βββ feature_name_ui_providers.dart
β βββ screens/
β β βββ feature_name_list_screen.dart
β β βββ feature_name_detail_screen.dart
β βββ widgets/
β βββ feature_name_list_item.dart
βββ providers/
βββ feature_name_providers.dart
lib/features/feature_name/
βββ models/
β βββ feature_name_model.dart
βββ presentation/ (optional with --no-ui flag)
β βββ providers/
β β βββ feature_name_ui_providers.dart
β βββ screens/
β β βββ feature_name_screen.dart
β βββ widgets/
β βββ feature_name_widget.dart
βββ providers/
βββ feature_name_providers.dart
lib/features/feature_name/
βββ models/
β βββ feature_name_model.dart
βββ presentation/
β βββ providers/
β β βββ feature_name_ui_providers.dart
β βββ widgets/
β βββ feature_name_widget.dart
βββ providers/
βββ feature_name_providers.dart
lib/features/feature_name/
βββ models/
β βββ feature_name_model.dart
βββ services/
β βββ feature_name_service.dart
βββ providers/
βββ feature_name_providers.dart
For programmatic usage in your own tools or scripts, you can also use the included Dart class:
// Import the generator
import 'package:flutter_riverpod_clean_architecture/core/cli/feature_generator.dart';
// Create and run the generator
final generator = FeatureGenerator(
featureName: 'user_profile',
withUi: true, // Include presentation layer
withTests: true, // Generate test files
withDocs: true // Create documentation
);
// Generate all files and folders
await generator.generate();
If you prefer to create features manually, follow this structure:
- Create the feature directory structure:
lib/features/feature_name/
βββ data/
β βββ datasources/ # Remote and local data sources
β βββ models/ # DTOs and model classes
β βββ repositories/ # Repository implementations
βββ domain/
β βββ entities/ # Business entities
β βββ repositories/ # Repository interfaces
β βββ usecases/ # Business use cases
βββ presentation/
β βββ providers/ # UI-specific providers
β βββ screens/ # Page/screen widgets
β βββ widgets/ # Reusable UI components
βββ providers/ # Core feature providers
Then implement each component:
Step 1: Define your entities in domain/entities/
- these are your core business models.
Step 2: Create repository interfaces in domain/repositories/
that define how data will be accessed.
Step 3: Implement use cases in domain/usecases/
for each business operation.
Step 4: Create data models in data/models/
that extend your entities with data layer functionality.
Step 5: Implement repositories in data/repositories/
that fulfill your repository interfaces.
Step 6: Create Riverpod providers in providers/feature_providers.dart
:
// Data source providers
final userRemoteDataSourceProvider = Provider<UserRemoteDataSource>((ref) =>
UserRemoteDataSourceImpl(client: ref.read(httpClientProvider)));
// Repository providers
final userRepositoryProvider = Provider<UserRepository>((ref) =>
UserRepositoryImpl(
remoteDataSource: ref.read(userRemoteDataSourceProvider),
localDataSource: ref.read(userLocalDataSourceProvider),
));
// Use case providers
final getUserProfileProvider = Provider((ref) =>
GetUserProfile(ref.read(userRepositoryProvider)));
// State providers
final userProfileProvider = FutureProvider<UserEntity>((ref) async {
final usecase = ref.read(getUserProfileProvider);
final result = await usecase(NoParams());
return result.fold(
(failure) => throw Exception(failure.toString()),
(user) => user,
);
});
Step 7: Create UI components in the presentation layer that consume your providers.
Step 8: Write tests for each layer in the corresponding test directory.
- Keep feature code isolated from other features
- Use dependency injection via Riverpod providers
- Follow the unidirectional data flow: UI β Use Case β Repository β Data Source
- Write tests for each layer, especially use cases and repositories
- Document feature usage and key integration points
Here's a comprehensive example of implementing a user profile feature using Clean Architecture:
// domain/entities/user_entity.dart
class UserEntity extends Equatable {
final String id;
final String name;
final String email;
final String? profileImage;
final DateTime lastActive;
const UserEntity({
required this.id,
required this.name,
required this.email,
this.profileImage,
required this.lastActive,
});
@override
List<Object?> get props => [id, name, email, profileImage, lastActive];
}
// domain/repositories/user_repository.dart
abstract class UserRepository {
/// Get the current user's profile
Future<Either<Failure, UserEntity>> getUserProfile();
/// Update the user's profile information
Future<Either<Failure, void>> updateUserProfile(UserEntity user);
/// Update just the profile image
Future<Either<Failure, String>> updateProfileImage(File imageFile);
}
// domain/usecases/get_user_profile.dart
class GetUserProfile implements UseCase<UserEntity, NoParams> {
final UserRepository repository;
GetUserProfile(this.repository);
@override
Future<Either<Failure, UserEntity>> call(NoParams params) {
return repository.getUserProfile();
}
}
// domain/usecases/update_user_profile.dart
class UpdateUserProfile implements UseCase<void, UpdateUserParams> {
final UserRepository repository;
UpdateUserProfile(this.repository);
@override
Future<Either<Failure, void>> call(UpdateUserParams params) {
return repository.updateUserProfile(params.user);
}
}
class UpdateUserParams extends Equatable {
final UserEntity user;
const UpdateUserParams({required this.user});
@override
List<Object> get props => [user];
}
// data/models/user_model.dart
class UserModel extends UserEntity {
UserModel({
required String id,
required String name,
required String email,
String? profileImage,
required DateTime lastActive,
}) : super(
id: id,
name: name,
email: email,
profileImage: profileImage,
lastActive: lastActive,
);
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
profileImage: json['profile_image'],
lastActive: DateTime.parse(json['last_active']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'profile_image': profileImage,
'last_active': lastActive.toIso8601String(),
};
}
// Convert entity to model
factory UserModel.fromEntity(UserEntity entity) {
return UserModel(
id: entity.id,
name: entity.name,
email: entity.email,
profileImage: entity.profileImage,
lastActive: entity.lastActive,
);
}
}
// data/datasources/user_remote_datasource.dart
class UserRemoteDataSourceImpl implements UserRemoteDataSource {
final http.Client client;
final AuthService authService;
UserRemoteDataSourceImpl({
required this.client,
required this.authService,
});
@override
Future<UserModel> getUserProfile() async {
final token = await authService.getToken();
final response = await client.get(
Uri.parse('${ApiConfig.baseUrl}/user/profile'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
return UserModel.fromJson(json.decode(response.body));
} else {
throw ServerException(
message: 'Failed to load profile',
statusCode: response.statusCode,
);
}
}
@override
Future<void> updateUserProfile(UserModel userModel) async {
final token = await authService.getToken();
final response = await client.put(
Uri.parse('${ApiConfig.baseUrl}/user/profile'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: json.encode(userModel.toJson()),
);
if (response.statusCode != 200) {
throw ServerException(
message: 'Failed to update profile',
statusCode: response.statusCode,
);
}
}
}
// data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
final UserLocalDataSource localDataSource;
final NetworkInfo networkInfo;
UserRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, UserEntity>> getUserProfile() async {
if (await networkInfo.isConnected) {
try {
final remoteUser = await remoteDataSource.getUserProfile();
localDataSource.cacheUserProfile(remoteUser);
return Right(remoteUser);
} on ServerException catch (e) {
return Left(ServerFailure(message: e.message));
}
} else {
try {
final localUser = await localDataSource.getCachedUserProfile();
return Right(localUser);
} on CacheException {
return Left(CacheFailure(message: 'No cached profile available'));
}
}
}
@override
Future<Either<Failure, void>> updateUserProfile(UserEntity user) async {
if (await networkInfo.isConnected) {
try {
final userModel = UserModel.fromEntity(user);
await remoteDataSource.updateUserProfile(userModel);
await localDataSource.cacheUserProfile(userModel);
return const Right(null);
} on ServerException catch (e) {
return Left(ServerFailure(message: e.message));
}
} else {
return Left(NetworkFailure(message: 'No internet connection'));
}
}
}
// providers/user_providers.dart
final userRemoteDataSourceProvider = Provider<UserRemoteDataSource>((ref) {
final client = ref.read(httpClientProvider);
final authService = ref.read(authServiceProvider);
return UserRemoteDataSourceImpl(
client: client,
authService: authService,
);
});
final userLocalDataSourceProvider = Provider<UserLocalDataSource>((ref) {
final storage = ref.read(secureStorageProvider);
return UserLocalDataSourceImpl(storage: storage);
});
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepositoryImpl(
remoteDataSource: ref.read(userRemoteDataSourceProvider),
localDataSource: ref.read(userLocalDataSourceProvider),
networkInfo: ref.read(networkInfoProvider),
);
});
final getUserProfileProvider = Provider<GetUserProfile>((ref) {
return GetUserProfile(ref.read(userRepositoryProvider));
});
final updateUserProfileProvider = Provider<UpdateUserProfile>((ref) {
return UpdateUserProfile(ref.read(userRepositoryProvider));
});
// State provider for the user profile
final userProfileProvider = FutureProvider<UserEntity>((ref) async {
final usecase = ref.read(getUserProfileProvider);
final result = await usecase(NoParams());
return result.fold(
(failure) => throw Exception(failure.message),
(user) => user,
);
});
// State provider for profile editing
final userProfileEditingProvider = StateNotifierProvider<UserProfileNotifier, AsyncValue<UserEntity?>>((ref) {
return UserProfileNotifier(ref);
});
class UserProfileNotifier extends StateNotifier<AsyncValue<UserEntity?>> {
final Ref ref;
UserProfileNotifier(this.ref) : super(const AsyncValue.loading()) {
_initUser();
}
Future<void> _initUser() async {
final currentUser = await ref.read(userProfileProvider.future);
state = AsyncValue.data(currentUser);
}
Future<void> updateProfile({
String? name,
String? email,
}) async {
if (state.value == null) return;
state = const AsyncValue.loading();
final updatedUser = UserEntity(
id: state.value!.id,
name: name ?? state.value!.name,
email: email ?? state.value!.email,
profileImage: state.value!.profileImage,
lastActive: DateTime.now(),
);
final result = await ref.read(updateUserProfileProvider).call(
UpdateUserParams(user: updatedUser)
);
state = result.fold(
(failure) => AsyncValue.error(failure, StackTrace.current),
(_) => AsyncValue.data(updatedUser),
);
// Invalidate the main user provider to fetch fresh data
ref.invalidate(userProfileProvider);
}
}
// presentation/screens/profile_screen.dart
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProfileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const EditProfileScreen(),
),
),
)
],
),
body: userAsync.when(
data: (user) => ProfileContent(user: user),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(userProfileProvider),
child: const Text('Retry'),
),
],
),
),
),
);
}
}
class ProfileContent extends StatelessWidget {
final UserEntity user;
const ProfileContent({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: ProfileAvatar(
imageUrl: user.profileImage,
name: user.name,
radius: 50,
),
),
const SizedBox(height: 24),
ProfileInfoCard(
title: 'Personal Information',
items: [
ProfileInfoItem(label: 'Name', value: user.name),
ProfileInfoItem(label: 'Email', value: user.email),
ProfileInfoItem(
label: 'Last Active',
value: DateFormat('MMM d, yyyy').format(user.lastActive),
),
],
),
],
);
}
}
// presentation/screens/edit_profile_screen.dart
class EditProfileScreen extends ConsumerStatefulWidget {
const EditProfileScreen({Key? key}) : super(key: key);
@override
_EditProfileScreenState createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
late TextEditingController _nameController;
late TextEditingController _emailController;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_emailController = TextEditingController();
// Initialize with current values
final currentUser = ref.read(userProfileProvider).value;
if (currentUser != null) {
_nameController.text = currentUser.name;
_emailController.text = currentUser.email;
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final editingState = ref.watch(userProfileEditingProvider);
ref.listen<AsyncValue<UserEntity?>>(
userProfileEditingProvider,
(_, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${next.error}')),
);
} else if (!next.isLoading && !next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profile updated!')),
);
Navigator.of(context).pop();
}
}
);
return Scaffold(
appBar: AppBar(title: const Text('Edit Profile')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 16),
TextField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: editingState.isLoading
? null
: () => _saveChanges(),
child: editingState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save Changes'),
),
)
],
),
),
);
}
void _saveChanges() {
ref.read(userProfileEditingProvider.notifier).updateProfile(
name: _nameController.text,
email: _emailController.text,
);
}
}
The template includes powerful command-line tools to streamline your development workflow:
Complete Development Tools Documentation
Generate complete feature modules with all Clean Architecture layers:
# Create a new feature with all layers
./generate_feature.sh --name feature_name
# Create a feature without UI layer
./generate_feature.sh --name data_service --no-ui
# Create a minimal feature
./generate_feature.sh --name analytics_tracker --no-ui --no-tests --no-docs
The Feature Generator creates a fully structured feature with:
- Data layer: Models, remote/local data sources, repository implementation
- Domain layer: Entities, repository interfaces, use cases
- Presentation layer: UI screens, widgets, Riverpod providers
- Tests: Unit tests for each layer
- Documentation: Feature usage guide
You can also use the programmatic API in your own tools:
final generator = FeatureGenerator(
featureName: 'user_profile',
withUi: true,
withTests: true,
withDocs: true
);
await generator.generate();
Behind the scenes, the feature generator:
- Creates the directory structure for data, domain, and presentation layers
- Generates properly formatted entity, repository, and model classes
- Sets up the Riverpod provider dependency chain for dependency injection
- Creates boilerplate for remote and local data sources
- Implements use cases with Either/Failure error handling
- Adds UI screens with proper state management (if UI enabled)
- Generates test files with proper mock setup (if tests enabled)
- Creates markdown documentation with usage examples (if docs enabled)
All generated code follows the project's coding standards and naming conventions, ensuring consistency across features.
Automate testing workflows with coverage reporting:
# Run all tests with coverage report
./test_generator.sh
# Run tests for a specific feature
./test_generator.sh --target test/features/auth/
# Run tests without coverage
./test_generator.sh --no-coverage
# Run tests without generating a report
./test_generator.sh --no-report
Option | Description |
---|---|
--target <path> |
Run tests only in the specified path |
--no-coverage |
Run tests without collecting coverage data |
--no-report |
Don't generate HTML coverage report |
--help |
Display help information |
The test generator:
- Runs Flutter tests with proper configuration
- Generates HTML coverage reports
- Opens reports in your default browser
- Provides CLI options for customizing test runs
This template is designed to provide a smooth, productive workflow for developing new features. Here's an optimal approach for adding functionality to your app:
Start by generating a new feature with all necessary layers:
./generate_feature.sh --name product_catalog
This creates all required files and folders with proper organization.
Next, work on the domain layer to define what your feature needs to accomplish:
- Update the entity in
domain/entities/product_catalog_entity.dart
- Define repository methods in
domain/repositories/product_catalog_repository.dart
- Create use cases for each business operation
Focus on defining the contract before implementation, thinking in terms of business requirements.
Now implement where your data comes from:
- Update the data model in
data/models/product_catalog_model.dart
- Implement the remote data source for API communication
- Implement the local data source for caching/persistence
- Complete the repository implementation that orchestrates the data sources
With the data flow working, build your user interface:
- Create the necessary screen layouts in the presentation layer
- Connect screens to providers for reactive state updates
- Implement error handling and loading states
- Add any specific UI providers needed for presentation state
Use the test generator to create and run tests for your feature:
# Run tests for your specific feature
./test_generator.sh --target test/features/product_catalog/
# Generate coverage report
./test_generator.sh
By following this workflow, you maintain a clear separation of concerns while ensuring your features are fully tested and align with Clean Architecture principles.
The template includes several command-line tools to accelerate development. Here's a quick reference:
Tool | Description | Usage |
---|---|---|
generate_feature.sh |
Creates a new feature with Clean Architecture structure | ./generate_feature.sh --name feature_name [options] |
test_generator.sh |
Runs tests with coverage reporting | ./test_generator.sh [--target path] [options] |
generate_language.sh |
Adds translations for internationalization | ./generate_language.sh --lang es [options] |
rename_app.sh |
Updates app name and bundle identifiers | ./rename_app.sh --name "New App Name" --bundle com.company.app |
create_feature.sh |
Alternative feature creator with different options | ./create_feature.sh feature_name |
To learn more about each tool's options, run any script with the --help
flag:
./generate_feature.sh --help
These tools follow consistent conventions to make development easier and faster while maintaining architectural integrity.
Not all features require the full repository pattern, especially for simpler UI components, utilities, or service wrappers. The current generator script creates the full Clean Architecture structure, but you can:
For very simple features like UI components or utilities, you can create a more direct structure:
lib/features/feature_name/
βββ models/ # Simple data models if needed
βββ providers/ # State providers
βββ presentation/
βββ screens/ # UI screens
βββ widgets/ # UI components
// lib/features/theme_switcher/models/theme_config.dart
class ThemeConfig {
final String name;
final Color primaryColor;
final Color accentColor;
final bool isDark;
const ThemeConfig({
required this.name,
required this.primaryColor,
required this.accentColor,
required this.isDark,
});
// Create predefined themes
static const light = ThemeConfig(
name: 'Light',
primaryColor: Colors.blue,
accentColor: Colors.blueAccent,
isDark: false,
);
static const dark = ThemeConfig(
name: 'Dark',
primaryColor: Colors.indigo,
accentColor: Colors.indigoAccent,
isDark: true,
);
}
// lib/features/theme_switcher/providers/theme_providers.dart
final availableThemesProvider = Provider<List<ThemeConfig>>((ref) {
return [ThemeConfig.light, ThemeConfig.dark];
});
final currentThemeProvider = StateNotifierProvider<ThemeNotifier, ThemeConfig>((ref) {
return ThemeNotifier();
});
class ThemeNotifier extends StateNotifier<ThemeConfig> {
ThemeNotifier() : super(ThemeConfig.light);
void setTheme(ThemeConfig theme) {
state = theme;
// Save preference if needed
}
void toggleTheme() {
state = state.isDark ? ThemeConfig.light : ThemeConfig.dark;
}
}
// lib/features/theme_switcher/presentation/widgets/theme_toggle_button.dart
class ThemeToggleButton extends ConsumerWidget {
const ThemeToggleButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDark = ref.watch(currentThemeProvider).isDark;
return IconButton(
icon: Icon(isDark ? Icons.light_mode : Icons.dark_mode),
onPressed: () {
ref.read(currentThemeProvider.notifier).toggleTheme();
},
tooltip: isDark ? 'Switch to light mode' : 'Switch to dark mode',
);
}
}