Skip to content
Abdullah Taş edited this page Sep 25, 2024 · 1 revision

Flutter GetX MVVM Clean Architecture Template Wiki

Welcome to the Flutter GetX MVVM Clean Architecture Template Wiki! This guide is designed to help you understand, set up, and utilize the template effectively for building scalable and maintainable Flutter applications. Whether you're a junior developer or someone new to Clean Architecture and GetX, this wiki will provide you with the necessary insights to get started.


Table of Contents

  1. Introduction
  2. Getting Started
  3. Folder Structure
  4. Packages Used
  5. Dependency Injection
  6. Routing with GetX
  7. State Management
  8. Models and JSON Serialization
  9. Services
  10. Controllers
  11. Views / Pages
  12. Error Handling
  13. Theming
  14. Running the Project
  15. Generating JSON Serialization Code
  16. Additional Configurations
  17. Conclusion
  18. Contact & Support

Introduction

What is this Template?

This Flutter GetX MVVM Clean Architecture Template serves as a robust foundation for developing Flutter applications. It integrates GetX for state management and routing, get_it for dependency injection, and adheres to the MVVM (Model-View-ViewModel) architecture combined with Clean Architecture principles. This structure promotes Object-Oriented Programming (OOP) and SOLID principles, ensuring that your codebase remains organized, scalable, and maintainable.

Key Features

  • MVVM Architecture: Separates the UI from business logic for cleaner code.
  • Clean Architecture: Promotes separation of concerns and scalability.
  • GetX Integration: Simplifies state management, routing, and dependency injection.
  • Dependency Injection with get_it: Manages dependencies efficiently.
  • Local Storage: Implements get_storage and flutter_secure_storage for data persistence.
  • Notifications: Handles local notifications on both Android and iOS using flutter_local_notifications.
  • Error Handling: Centralizes error management for better debugging and user experience.
  • Theming: Supports light and dark themes with easy customization.

Getting Started

Prerequisites

Before diving into the template, ensure you have the following installed on your machine:

  • Flutter SDK: Installation Guide
  • Dart SDK: Comes bundled with Flutter.
  • IDE: Visual Studio Code, Android Studio, or any other preferred IDE.
  • Device/Emulator: Set up an Android/iOS emulator or use a physical device.

Clone the Repository

Start by cloning the repository to your local machine:

git clone https://github.com/your-username/flutter-getx-clean-architecture-template.git cd flutter-getx-clean-architecture-template

Install Dependencies

Navigate to the project directory and install the required packages:

flutter pub get

Generate JSON Serialization Code

This project uses json_serializable for JSON parsing. Generate the necessary serialization code by running:

flutter pub run build_runner build --delete-conflicting-outputs

Run the App

Ensure that you have a device connected or an emulator running, then execute:

flutter run

Folder Structure

A well-organized folder structure is vital for maintaining a scalable and manageable codebase. Here's an overview of the project's structure:

vbnet
lib/ ├── core/ │ ├── error/ │ │ ├── exceptions.dart │ │ └── failures.dart │ ├── middleware/ │ │ └── auth_middleware.dart │ ├── services/ │ │ ├── api_service.dart │ │ ├── api_service_impl.dart │ │ ├── connectivity_service.dart │ │ ├── connectivity_service_impl.dart │ │ ├── notification_service.dart │ │ └── storage_service.dart │ ├── utils/ │ │ ├── logger.dart │ │ └── theme.dart │ └── constants/ │ └── constants.dart ├── data/ │ ├── datasources/ │ │ ├── remote_data_source.dart │ │ └── remote_data_source_impl.dart │ ├── models/ │ │ └── user_model.dart │ └── repositories/ │ └── user_repository_impl.dart ├── domain/ │ ├── entities/ │ │ └── user.dart │ ├── repositories/ │ │ └── user_repository.dart │ └── usecases/ │ └── get_users_usecase.dart ├── presentation/ │ ├── bindings/ │ │ ├── home_binding.dart │ │ └── initial_binding.dart │ ├── controllers/ │ │ └── user_controller.dart │ ├── pages/ │ │ ├── home_page.dart │ │ └── splash_page.dart │ ├── widgets/ │ │ └── user_list_widget.dart │ └── utils/ │ └── responsive.dart ├── routes/ │ ├── app_pages.dart │ └── app_routes.dart ├── injection_container.dart └── main.dart

Folder Descriptions

  • core/: Contains fundamental components like error handling, services, utilities, middleware, and constants used across the entire application.

  • data/: Manages data-related tasks, including data sources (e.g., API interactions), models (data representations), and repository implementations that fetch data from various sources.

  • domain/: Encapsulates the business logic, including entities (core data structures), repository interfaces, and use cases (application-specific business rules).

  • presentation/: Handles the UI layer, including bindings (dependency injection for views), controllers (state management), pages (UI screens), widgets (reusable UI components), and utility classes.

  • routes/: Manages application routing using GetX, defining pages and route names.

  • injection_container.dart: Sets up and registers all dependencies using get_it.

  • main.dart: The entry point of the application, initializing dependencies and running the app.


Packages Used

The project leverages several Flutter packages to implement its features efficiently. Here's a breakdown of the primary packages used:

Package | Version | Purpose -- | -- | -- get | ^4.6.1 | State management, dependency injection, and routing solution. get_storage | ^2.0.3 | Lightweight key-value storage for simple data persistence. dio | ^4.0.0 | Powerful HTTP client for making API requests. json_annotation | ^4.0.1 | Annotations to generate JSON serialization code for models. get_it | ^7.2.0 | Service locator for dependency injection, managing app-wide dependencies. connectivity_plus | ^4.0.1 | Detects network connectivity status changes. flutter_local_notifications | ^12.0.4 | Manages local notifications on both Android and iOS. logger | ^1.2.0 | Provides easy-to-use logging capabilities. equatable | ^2.0.5 | Simplifies value comparisons, particularly for state management. intl | ^0.18.1 | Internationalization and localization support. flutter_secure_storage | ^8.0.0 | Secure storage for sensitive data like tokens and credentials. flutter_test | SDK | Testing framework for Flutter applications. flutter_lints | ^3.0.0 | Linting rules to enforce code quality and consistency. build_runner | ^2.1.4 | Automates code generation tasks, such as JSON serialization. json_serializable | ^6.0.1 | Generates JSON serialization boilerplate code based on model annotations.

Dependency Injection

Dependency Injection (DI) is a design pattern that allows a class to receive its dependencies from external sources rather than creating them itself. This promotes loose coupling and makes the code more testable and maintainable.

Using get_it for Dependency Injection

This template utilizes get_it as the service locator for managing dependencies across the application.

Setting Up get_it

File: lib/injection_container.dart

dart
import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:logger/logger.dart'; import 'package:get_storage/get_storage.dart';

// Import your services, datasources, repositories, usecases, controllers, etc. import 'core/services/api_service.dart'; import 'core/services/api_service_impl.dart'; import 'core/services/connectivity_service.dart'; import 'core/services/connectivity_service_impl.dart'; import 'core/services/notification_service.dart'; import 'core/services/storage_service.dart'; import 'data/datasources/remote_data_source.dart'; import 'data/datasources/remote_data_source_impl.dart'; import 'data/repositories/user_repository_impl.dart'; import 'domain/repositories/user_repository.dart'; import 'domain/usecases/get_users_usecase.dart'; import 'presentation/controllers/user_controller.dart';

final sl = GetIt.instance;

Future<void> init() async { // Core sl.registerLazySingleton(() => Logger()); sl.registerLazySingleton(() => Dio());

// Services sl.registerLazySingleton<ApiService>(() => ApiServiceImpl(dio: sl())); sl.registerLazySingleton<ConnectivityService>(() => ConnectivityServiceImpl());

// Data Sources sl.registerLazySingleton<RemoteDataSource>(() => RemoteDataSourceImpl(apiService: sl()));

// Repositories sl.registerLazySingleton<UserRepository>(() => UserRepositoryImpl(remoteDataSource: sl()));

// Use Cases sl.registerLazySingleton<GetUsersUseCase>(() => GetUsersUseCase(repository: sl()));

// Controllers sl.registerFactory<UserController>(() => UserController(getUsersUseCase: sl()));

// Notification Service sl.registerLazySingleton<NotificationService>(() => NotificationService());

// Storage Service sl.registerLazySingleton<StorageService>(() => StorageService());

// Initialize GetStorage await sl.registerSingletonAsync<GetStorage>(() async { await GetStorage.init(); return GetStorage(); }); }

Registering Dependencies

  • Singletons: For services that should have a single instance throughout the app's lifecycle (e.g., Logger, Dio, ApiService), use registerLazySingleton.

  • Factories: For objects that should be recreated each time they are requested (e.g., UserController), use registerFactory.

Initializing Dependencies

Ensure that get_it is initialized before running the app. This is typically done in the main.dart file.


Routing with GetX

GetX simplifies routing by allowing you to define routes and navigate between pages effortlessly. It supports both mobile and web platforms, ensuring seamless navigation across different devices.

Defining Routes

Routes are defined in the routes/ folder, primarily in app_pages.dart and app_routes.dart.

app_pages.dart

Maps route names to their corresponding pages and bindings.

File: lib/routes/app_pages.dart

dart
import 'package:get/get.dart';

import '../presentation/pages/home_page.dart'; import '../presentation/pages/splash_page.dart'; import 'app_routes.dart'; import '../presentation/bindings/initial_binding.dart';

class AppPages { static const initial = Routes.SPLASH;

static final routes = [ GetPage( name: Routes.SPLASH, page: () => SplashPage(), binding: InitialBinding(), // Register initial dependencies ), GetPage( name: Routes.HOME, page: () => HomePage(), binding: HomeBinding(), ), // Add more GetPage entries here as needed ]; }

app_routes.dart

Contains the Routes class with static constants representing each route name.

File: lib/routes/app_routes.dart

dart
abstract class Routes { static const SPLASH = '/splash'; static const HOME = '/home'; // Add more route constants here }

Navigating Between Pages

Use GetX's navigation methods to move between pages.

  • Navigate to Home Page:

    dart
    Get.toNamed(Routes.HOME);
  • Navigate and Remove Previous Routes (e.g., after Splash):

    dart
    Get.offNamed(Routes.HOME);
  • Navigate with Arguments:

    dart
    Get.toNamed(Routes.DETAIL, arguments: {'id': userId});
  • Handling Unknown Routes:

    Define an unknown route to handle navigation to undefined routes.

    dart
    GetMaterialApp( // ... other configurations unknownRoute: GetPage( name: '/notfound', page: () => NotFoundPage(), ), );

Bindings

Bindings are used to inject dependencies when navigating to a route.

  • Initial Binding:

    Registers global dependencies when the app starts.

    File: lib/presentation/bindings/initial_binding.dart

    dart
    import 'package:get/get.dart'; import '../../core/services/api_service.dart'; import '../../core/services/connectivity_service.dart'; import '../../core/services/notification_service.dart'; import '../../core/services/storage_service.dart'; import '../../data/datasources/remote_data_source.dart'; import '../../data/datasources/remote_data_source_impl.dart'; import '../../data/repositories/user_repository_impl.dart'; import '../../domain/repositories/user_repository.dart'; import '../../domain/usecases/get_users_usecase.dart'; import '../controllers/user_controller.dart';

    class InitialBinding extends Bindings { @override void dependencies() { // Register all global dependencies here // This is handled in get_it, so InitialBinding may remain empty or handle additional dependencies } }

  • Home Binding:

    Injects dependencies specific to the Home page.

    File: lib/presentation/bindings/home_binding.dart

    dart
    import 'package:get/get.dart'; import '../controllers/user_controller.dart'; import '../../injection_container.dart' as di;

    class HomeBinding extends Bindings { @override void dependencies() { Get.lazyPut<UserController>( () => UserController(getUsersUseCase: di.sl<GetUsersUseCase>()), ); } }


State Management

State management is crucial for maintaining the state of your application across different widgets and pages. This template utilizes GetX for efficient and straightforward state management.

UserController

Controllers in GetX extend GetxController and manage the state and business logic for specific parts of the app.

File: lib/presentation/controllers/user_controller.dart

dart
import 'package:get/get.dart'; import 'package:flutter_getx_clean_architecture/domain/entities/user.dart'; import 'package:flutter_getx_clean_architecture/domain/usecases/get_users_usecase.dart';

class UserController extends GetxController { final GetUsersUseCase getUsersUseCase;

UserController({required this.getUsersUseCase});

var users = <User>[].obs; var isLoading = false.obs; var error = ''.obs;

@override void onInit() { super.onInit(); fetchUsers(); }

Future<void> fetchUsers() async { try { isLoading.value = true; var fetchedUsers = await getUsersUseCase(); users.assignAll(fetchedUsers); } catch (e) { error.value = e.toString(); } finally { isLoading.value = false; } } }

Explanation

  • users: An observable list of User entities that the UI listens to for changes.
  • isLoading: An observable boolean indicating whether data is being fetched.
  • error: An observable string that holds any error messages.
  • fetchUsers(): A method that retrieves users using the GetUsersUseCase and updates the observables accordingly.

Using the Controller in Views

Controllers are accessed using Get.find() within the view components.

Example: lib/presentation/pages/home_page.dart

dart
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/user_controller.dart';

class HomePage extends StatelessWidget { final UserController controller = Get.find<UserController>();

@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Users'), ), body: Obx(() { if (controller.isLoading.value) { return Center(child: CircularProgressIndicator()); }

    if (controller.error.isNotEmpty) {
      return Center(child: Text(controller.error.value));
    }

    return ListView.builder(
      itemCount: controller.users.length,
      itemBuilder: (context, index) {
        final user = controller.users[index];
        return ListTile(
          title: Text(user.name),
          subtitle: Text(user.email),
        );
      },
    );
  }),
);

} }

Explanation

  • Obx Widget: Rebuilds its child whenever any of the observables (isLoading, error, users) change.
  • Conditional Rendering: Displays a loading indicator, error message, or the list of users based on the current state.

Models and JSON Serialization

Models represent the data structures used within the app. This template uses json_serializable to automate JSON parsing and serialization.

UserModel

File: lib/data/models/user_model.dart

dart
import 'package:json_annotation/json_annotation.dart'; import 'package:flutter_getx_clean_architecture/domain/entities/user.dart';

part 'user_model.g.dart';

@JsonSerializable() class UserModel { final int id; final String name; final String username; final String email;

UserModel({ required this.id, required this.name, required this.username, required this.email, });

factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);

Map<String, dynamic> toJson() => _$UserModelToJson(this);

// Convert UserModel to User entity User toEntity() { return User( id: id, name: name, username: username, email: email, ); } }

Explanation

  • Annotations: @JsonSerializable() tells the json_serializable package to generate serialization code.
  • fromJson and toJson: Factory methods generated by build_runner to parse JSON data.
  • toEntity(): Converts UserModel to the domain entity User.

Generating Serialization Code

After defining your models, generate the serialization code by running:

flutter pub run build_runner build --delete-conflicting-outputs

This command generates the user_model.g.dart file containing the fromJson and toJson methods.


Services

Services encapsulate reusable functionalities like API interactions, connectivity monitoring, notifications, and storage. This template includes several services to handle these aspects efficiently.

API Service

Handles all API interactions using Dio.

api_service.dart

File: lib/core/services/api_service.dart

dart
abstract class ApiService { Future<dynamic> get(String url, {Map<String, dynamic>? queryParameters}); // Add more methods like post, put, delete as needed }

api_service_impl.dart

File: lib/core/services/api_service_impl.dart

dart
import 'package:dio/dio.dart'; import 'api_service.dart'; import '../error/exceptions.dart';

class ApiServiceImpl implements ApiService { final Dio dio;

ApiServiceImpl({required this.dio});

@override Future<dynamic> get(String url, {Map<String, dynamic>? queryParameters}) async { try { final response = await dio.get(url, queryParameters: queryParameters); return response.data; } on DioError catch (e) { // Handle Dio errors throw ServerException(e.message); } }

// Implement other methods (post, put, delete) similarly }

Connectivity Service

Monitors network connectivity status using connectivity_plus.

connectivity_service.dart

File: lib/core/services/connectivity_service.dart

dart
abstract class ConnectivityService { Future<bool> get isConnected; }

connectivity_service_impl.dart

File: lib/core/services/connectivity_service_impl.dart

dart
import 'package:connectivity_plus/connectivity_plus.dart'; import 'connectivity_service.dart';

class ConnectivityServiceImpl implements ConnectivityService { final Connectivity _connectivity = Connectivity();

@override Future<bool> get isConnected async { var result = await _connectivity.checkConnectivity(); return result != ConnectivityResult.none; } }

Notification Service

Manages local notifications on both Android and iOS using flutter_local_notifications.

notification_service.dart

File: lib/core/services/notification_service.dart

dart
import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:io' show Platform;

class NotificationService { final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

Future<void> init(BuildContext context) async { // Android Initialization const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');

// iOS Initialization
final IOSInitializationSettings initializationSettingsIOS =
    IOSInitializationSettings(
  requestAlertPermission: true,
  requestBadgePermission: true,
  requestSoundPermission: true,
  onDidReceiveLocalNotification:
      (int id, String? title, String? body, String? payload) async {
    // Display an alert dialog when a notification is received in foreground
    showDialog(
      context: context,
      builder: (BuildContext context) =&gt; AlertDialog(
        title: Text(title ?? 'Notification'),
        content: Text(body ?? ''),
        actions: [
          TextButton(
            onPressed: () =&gt; Navigator.of(context).pop(),
            child: Text('OK'),
          ),
        ],
      ),
    );
  },
);

// Combine Initialization Settings
final InitializationSettings initializationSettings =
    InitializationSettings(
  android: initializationSettingsAndroid,
  iOS: initializationSettingsIOS,
  macOS: null, // Add macOS settings if needed
);

// Initialize the plugin
await flutterLocalNotificationsPlugin.initialize(
  initializationSettings,
  onSelectNotification: onSelectNotification,
);

}

// Handle notification tapped logic Future<void> onSelectNotification(String? payload) async { if (payload != null) { // Navigate to a specific screen based on the payload // Example: // Get.toNamed('/detail', arguments: payload); } }

Future<void> showNotification( {required String title, required String body}) async { const AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails( 'your_channel_id', // Channel ID 'your_channel_name', // Channel Name channelDescription: 'your_channel_description', // Channel Description importance: Importance.max, priority: Priority.high, showWhen: false, );

const IOSNotificationDetails iOSPlatformChannelSpecifics =
    IOSNotificationDetails(
  // Add iOS-specific settings if needed
  // e.g., sound: 'default.wav',
);

const NotificationDetails platformChannelSpecifics = NotificationDetails(
  android: androidPlatformChannelSpecifics,
  iOS: iOSPlatformChannelSpecifics,
);

await flutterLocalNotificationsPlugin.show(
  0, // Notification ID
  title,
  body,
  platformChannelSpecifics,
  payload: 'item x', // Optional payload
);

} }

Storage Service

Handles data persistence using get_storage and flutter_secure_storage.

storage_service.dart

File: lib/core/services/storage_service.dart

dart
import 'package:get_storage/get_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class StorageService { final GetStorage _storage = GetStorage(); final FlutterSecureStorage _secureStorage = FlutterSecureStorage();

// Get Storage Methods dynamic read(String key) => _storage.read(key); Future<void> write(String key, dynamic value) => _storage.write(key, value); Future<void> remove(String key) => _storage.remove(key);

// Secure Storage Methods Future<void> writeSecure(String key, String value) => _secureStorage.write(key: key, value: value); Future<String?> readSecure(String key) => _secureStorage.read(key: key); Future<void> deleteSecure(String key) => _secureStorage.delete(key: key); }


Controllers

Controllers manage the state and business logic of your application. They extend GetX's GetxController and interact with use cases to perform actions.

UserController

File: lib/presentation/controllers/user_controller.dart

dart
import 'package:get/get.dart'; import 'package:flutter_getx_clean_architecture/domain/entities/user.dart'; import 'package:flutter_getx_clean_architecture/domain/usecases/get_users_usecase.dart';

class UserController extends GetxController { final GetUsersUseCase getUsersUseCase;

UserController({required this.getUsersUseCase});

var users = <User>[].obs; var isLoading = false.obs; var error = ''.obs;

@override void onInit() { super.onInit(); fetchUsers(); }

Future<void> fetchUsers() async { try { isLoading.value = true; var fetchedUsers = await getUsersUseCase(); users.assignAll(fetchedUsers); } catch (e) { error.value = e.toString(); } finally { isLoading.value = false; } } }

Explanation

  • users: An observable list of User entities that the UI listens to for changes.
  • isLoading: An observable boolean indicating whether data is being fetched.
  • error: An observable string that holds any error messages.
  • fetchUsers(): A method that retrieves users using the GetUsersUseCase and updates the observables accordingly.

Views / Pages

Views, also known as Pages, are the UI components of your application. They interact with controllers to display data and respond to user interactions.

Splash Page

File: lib/presentation/pages/splash_page.dart

dart
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../routes/app_pages.dart';

class SplashPage extends StatelessWidget { @override Widget build(BuildContext context) { // Navigate to Home after a delay Future.delayed(Duration(seconds: 2), () { Get.offNamed(Routes.HOME); });

return Scaffold(
  body: Center(
    child: Text(
      'GetX MVVM Template',
      style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
    ),
  ),
);

} }

Home Page

File: lib/presentation/pages/home_page.dart

dart
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/user_controller.dart';

class HomePage extends StatelessWidget { final UserController controller = Get.find<UserController>();

@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Users'), ), body: Obx(() { if (controller.isLoading.value) { return Center(child: CircularProgressIndicator()); }

    if (controller.error.isNotEmpty) {
      return Center(child: Text(controller.error.value));
    }

    return ListView.builder(
      itemCount: controller.users.length,
      itemBuilder: (context, index) {
        final user = controller.users[index];
        return ListTile(
          title: Text(user.name),
          subtitle: Text(user.email),
        );
      },
    );
  }),
);

} }

Explanation

  • SplashPage: The initial screen displayed when the app launches. It typically shows a loading indicator or logo before navigating to the Home page.

  • HomePage: Displays a list of users fetched from the API. It observes the UserController for changes in the user list, loading state, and errors.


Models and JSON Serialization

Models represent the data structures used within the app. This template uses json_serializable to automate JSON parsing and serialization.

UserModel

File: lib/data/models/user_model.dart

dart
import 'package:json_annotation/json_annotation.dart'; import 'package:flutter_getx_clean_architecture/domain/entities/user.dart';

part 'user_model.g.dart';

@JsonSerializable() class UserModel { final int id; final String name; final String username; final String email;

UserModel({ required this.id, required this.name, required this.username, required this.email, });

factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);

Map<String, dynamic> toJson() => _$UserModelToJson(this);

// Convert UserModel to User entity User toEntity() { return User( id: id, name: name, username: username, email: email, ); } }

Explanation

  • Annotations: @JsonSerializable() tells the json_serializable package to generate serialization code.

  • fromJson and toJson: Factory methods generated by build_runner to parse JSON data.

  • toEntity(): Converts UserModel to the domain entity User.

Generating Serialization Code

After defining your models, generate the serialization code by running:

flutter pub run build_runner build --delete-conflicting-outputs

This command generates the user_model.g.dart file containing the fromJson and toJson methods.


Services

Services encapsulate reusable functionalities like API interactions, connectivity monitoring, notifications, and storage. This template includes several services to handle these aspects efficiently.

API Service

Handles all API interactions using Dio.

api_service.dart

File: lib/core/services/api_service.dart

dart
abstract class ApiService { Future<dynamic> get(String url, {Map<String, dynamic>? queryParameters}); // Add more methods like post, put, delete as needed }

api_service_impl.dart

File: lib/core/services/api_service_impl.dart

dart
import 'package:dio/dio.dart'; import 'api_service.dart'; import '../error/exceptions.dart';

class ApiServiceImpl implements ApiService { final Dio dio;

ApiServiceImpl({required this.dio});

@override Future<dynamic> get(String url, {Map<String, dynamic>? queryParameters}) async { try { final response = await dio.get(url, queryParameters: queryParameters); return response.data; } on DioError catch (e) { // Handle Dio errors throw ServerException(e.message); } }

// Implement other methods (post, put, delete) similarly }

Connectivity Service

Monitors network connectivity status using connectivity_plus.

connectivity_service.dart

File: lib/core/services/connectivity_service.dart

dart
abstract class ConnectivityService { Future<bool> get isConnected; }

connectivity_service_impl.dart

File: lib/core/services/connectivity_service_impl.dart

dart
import 'package:connectivity_plus/connectivity_plus.dart'; import 'connectivity_service.dart';

class ConnectivityServiceImpl implements ConnectivityService { final Connectivity _connectivity = Connectivity();

@override Future<bool> get isConnected async { var result = await _connectivity.checkConnectivity(); return result != ConnectivityResult.none; } }

Notification Service

Manages local notifications on both Android and iOS using flutter_local_notifications.

notification_service.dart

File: lib/core/services/notification_service.dart

dart
import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:io' show Platform;

class NotificationService { final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

Future<void> init(BuildContext context) async { // Android Initialization const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');

// iOS Initialization
final IOSInitializationSettings initializationSettingsIOS =
    IOSInitializationSettings(
  requestAlertPermission: true,
  requestBadgePermission: true,
  requestSoundPermission: true,
  onDidReceiveLocalNotification:
      (int id, String? title, String? body, String? payload) async {
    // Display an alert dialog when a notification is received in foreground
    showDialog(
      context: context,
      builder: (BuildContext context) =&gt; AlertDialog(
        title: Text(title ?? 'Notification'),
        content: Text(body ?? ''),
        actions: [
          TextButton(
            onPressed: () =&gt; Navigator.of(context).pop(),
            child: Text('OK'),
          ),
        ],
      ),
    );
  },
);

// Combine Initialization Settings
final InitializationSettings initializationSettings =
    InitializationSettings(
  android: initializationSettingsAndroid,
  iOS: initializationSettingsIOS,
  macOS: null, // Add macOS settings if needed
);

// Initialize the plugin
await flutterLocalNotificationsPlugin.initialize(
  initializationSettings,
  onSelectNotification: onSelectNotification,
);

}

// Handle notification tapped logic Future<void> onSelectNotification(String? payload) async { if (payload != null) { // Navigate to a specific screen based on the payload // Example: // Get.toNamed('/detail', arguments: payload); } }

Future<void> showNotification( {required String title, required String body}) async { const AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails( 'your_channel_id', // Channel ID 'your_channel_name', // Channel Name channelDescription: 'your_channel_description', // Channel Description importance: Importance.max, priority: Priority.high, showWhen: false, );

const IOSNotificationDetails iOSPlatformChannelSpecifics =
    IOSNotificationDetails(
  // Add iOS-specific settings if needed
  // e.g., sound: 'default.wav',
);

const NotificationDetails platformChannelSpecifics = NotificationDetails(
  android: androidPlatformChannelSpecifics,
  iOS: iOSPlatformChannelSpecifics,
);

await flutterLocalNotificationsPlugin.show(
  0, // Notification ID
  title,
  body,
  platformChannelSpecifics,
  payload: 'item x', // Optional payload
);

} }

Storage Service

Handles data persistence using get_storage and flutter_secure_storage.

storage_service.dart

File: lib/core/services/storage_service.dart

dart
import 'package:get_storage/get_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class StorageService { final GetStorage _storage = GetStorage(); final FlutterSecureStorage _secureStorage = FlutterSecureStorage();

// Get Storage Methods dynamic read(String key) => _storage.read(key); Future<void> write(String key, dynamic value) => _storage.write(key, value); Future<void> remove(String key) => _storage.remove(key);

// Secure Storage Methods Future<void> writeSecure(String key, String value) => _secureStorage.write(key: key, value: value); Future<String?> readSecure(String key) => _secureStorage.read(key: key); Future<void> deleteSecure(String key) => _secureStorage.delete(key: key); }

Explanation

  • get_storage: Used for simple key-value storage, suitable for non-sensitive data.
  • flutter_secure_storage: Used for securely storing sensitive data like tokens and credentials.

Usage Examples

  • Storing a Non-Sensitive Value:

    dart
    final storageService = di.sl<StorageService>();

    // Write await storageService.write('username', 'john_doe');

    // Read var username = storageService.read('username');

    // Remove await storageService.remove('username');

  • Storing a Sensitive Value:

    dart
    final storageService = di.sl<StorageService>();

    // Write Secure await storageService.writeSecure('auth_token', 'secure_token_value');

    // Read Secure var authToken = await storageService.readSecure('auth_token');

    // Delete Secure await storageService.deleteSecure('auth_token');


Controllers

Controllers manage the state and business logic of your application. They extend GetX's GetxController and interact with use cases to perform actions.

UserController

File: lib/presentation/controllers/user_controller.dart

dart
import 'package:get/get.dart'; import 'package:flutter_getx_clean_architecture/domain/entities/user.dart'; import 'package:flutter_getx_clean_architecture/domain/usecases/get_users_usecase.dart';

class UserController extends GetxController { final GetUsersUseCase getUsersUseCase;

UserController({required this.getUsersUseCase});

var users = <User>[].obs; var isLoading = false.obs; var error = ''.obs;

@override void onInit() { super.onInit(); fetchUsers(); }

Future<void> fetchUsers() async { try { isLoading.value = true; var fetchedUsers = await getUsersUseCase(); users.assignAll(fetchedUsers); } catch (e) { error.value = e.toString(); } finally { isLoading.value = false; } } }

Explanation

  • users: An observable list of User entities that the UI listens to for changes.
  • isLoading: An observable boolean indicating whether data is being fetched.
  • error: An observable string that holds any error messages.
  • fetchUsers(): A method that retrieves users using the GetUsersUseCase and updates the observables accordingly.

Views / Pages

Views, also known as Pages, are the UI components of your application. They interact with controllers to display data and respond to user interactions.

Splash Page

File: lib/presentation/pages/splash_page.dart

dart
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../routes/app_pages.dart';

class SplashPage extends StatelessWidget { @override Widget build(BuildContext context) { // Navigate to Home after a delay Future.delayed(Duration(seconds: 2), () { Get.offNamed(Routes.HOME); });

return Scaffold(
  body: Center(
    child: Text(
      'GetX MVVM Template',
      style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
    ),
  ),
);

} }

Home Page

File: lib/presentation/pages/home_page.dart

dart
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/user_controller.dart';

class HomePage extends StatelessWidget { final UserController controller = Get.find<UserController>();

@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Users'), ), body: Obx(() { if (controller.isLoading.value) { return Center(child: CircularProgressIndicator()); }

    if (controller.error.isNotEmpty) {
      return Center(child: Text(controller.error.value));
    }

    return ListView.builder(
      itemCount: controller.users.length,
      itemBuilder: (context, index) {
        final user = controller.users[index];
        return ListTile(
          title: Text(user.name),
          subtitle: Text(user.email),
        );
      },
    );
  }),
);

} }

Explanation

  • SplashPage: The initial screen displayed when the app launches. It typically shows a loading indicator or logo before navigating to the Home page.

  • HomePage: Displays a list of users fetched from the API. It observes the UserController for changes in the user list, loading state, and errors.


Error Handling

Effective error handling enhances the user experience and simplifies debugging. This template centralizes error management to ensure consistent and informative error responses across the application.

Exceptions

File: lib/core/error/exceptions.dart

dart
class ServerException implements Exception { final String message;

ServerException(this.message); }

class CacheException implements Exception { final String message;

CacheException(this.message); }

Failures

File: lib/core/error/failures.dart

dart
abstract class Failure { final String message;

Failure(this.message); }

class ServerFailure extends Failure { ServerFailure(String message) : super(message); }

class CacheFailure extends Failure { CacheFailure(String message) : super(message); }

Handling Errors in Repositories

File: lib/data/repositories/user_repository_impl.dart

dart
import 'package:flutter_getx_clean_architecture/core/error/exceptions.dart'; import 'package:flutter_getx_clean_architecture/core/error/failures.dart'; import 'package:flutter_getx_clean_architecture/data/datasources/remote_data_source.dart'; import 'package:flutter_getx_clean_architecture/domain/entities/user.dart'; import 'package:flutter_getx_clean_architecture/domain/repositories/user_repository.dart';

class UserRepositoryImpl implements UserRepository { final RemoteDataSource remoteDataSource;

UserRepositoryImpl({required this.remoteDataSource});

@override Future<List<User>> getUsers() async { try { final List<UserModel> users = await remoteDataSource.fetchUsers(); return users.map((model) => model.toEntity()).toList(); } on ServerException catch (e) { throw ServerFailure(e.message); } catch (e) { throw ServerFailure('Unexpected error'); } } }

Displaying Errors in the UI

File: lib/presentation/pages/home_page.dart

dart
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/user_controller.dart';

class HomePage extends StatelessWidget { final UserController controller = Get.find<UserController>();

@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Users'), ), body: Obx(() { if (controller.isLoading.value) { return Center(child: CircularProgressIndicator()); }

    if (controller.error.isNotEmpty) {
      return Center(child: Text(controller.error.value));
    }

    return ListView.builder(
      itemCount: controller.users.length,
      itemBuilder: (context, index) {
        final user = controller.users[index];
        return ListTile(
          title: Text(user.name),
          subtitle: Text(user.email),
        );
      },
    );
  }),
);

} }

Explanation

  • Exceptions: Custom exception classes that represent different error scenarios.

  • Failures: Classes that encapsulate error information, used to communicate errors between layers.

  • Repository Error Handling: Catches exceptions thrown during data fetching and converts them into Failure instances.

  • UI Error Display: Observes the error observable in the controller and displays error messages accordingly.


Theming

The application supports both light and dark themes, allowing users to choose their preferred appearance or follow the system's theme.

Theme Configuration

File: lib/core/utils/theme.dart

dart
import 'package:flutter/material.dart';

class AppTheme { static final ThemeData lightTheme = ThemeData( primarySwatch: Colors.blue, brightness: Brightness.light, // Define other theme properties appBarTheme: AppBarTheme( color: Colors.blue, elevation: 0, ), // Add more theming as needed );

static final ThemeData darkTheme = ThemeData( primarySwatch: Colors.blue, brightness: Brightness.dark, // Define other theme properties appBarTheme: AppBarTheme( color: Colors.black, elevation: 0, ), // Add more theming as needed ); }

Applying Themes in main.dart

File: lib/main.dart

dart
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'injection_container.dart' as di; import 'routes/app_pages.dart'; import 'core/utils/theme.dart'; import 'package:get_storage/get_storage.dart'; import 'package:url_strategy/url_strategy.dart'; import 'core/services/notification_service.dart';

void main() async { WidgetsFlutterBinding.ensureInitialized(); setPathUrlStrategy(); // Optional: Removes the hash (#) from URLs for web await di.init(); runApp(MyApp()); }

class MyApp extends StatelessWidget { final NotificationService _notificationService = di.sl<NotificationService>();

@override Widget build(BuildContext context) { // Initialize notifications with context if needed // _notificationService.init(context); // Adjust if necessary

return GetMaterialApp(
  title: 'GetX MVVM Template',
  debugShowCheckedModeBanner: false,
  initialRoute: AppPages.initial,
  getPages: AppPages.routes,
  theme: AppTheme.lightTheme,
  darkTheme: AppTheme.darkTheme,
  themeMode: ThemeMode.system, // Can be ThemeMode.light or ThemeMode.dark
  // Optionally handle unknown routes
  unknownRoute: GetPage(
    name: '/notfound',
    page: () =&gt; NotFoundPage(),
  ),
);

} }

Switching Themes Dynamically

You can allow users to switch themes dynamically by updating the themeMode property.

Example:

dart
// Toggle Theme void toggleTheme() { if (Get.isDarkMode) { Get.changeThemeMode(ThemeMode.light); } else { Get.changeThemeMode(ThemeMode.dark); } }

You can call this method from a button press or any other user interaction.


Running the Project

Follow these steps to set up and run the project on your local machine.

Prerequisites

  • Flutter SDK: Ensure you have Flutter installed. Installation Guide
  • IDE: Use Android Studio, VS Code, or any other preferred IDE.
  • Device/Emulator: Set up an Android/iOS emulator or use a physical device.

Steps

  1. Clone the Repository

    git clone https://github.com/your-username/flutter-getx-clean-architecture-template.git cd flutter-getx-clean-architecture-template
  2. Install Dependencies

    flutter pub get
  3. Generate JSON Serialization Code

    This step generates necessary code for JSON serialization based on model annotations.

    flutter pub run build_runner build --delete-conflicting-outputs
  4. Run the App

    • On Android Emulator or Device:

      flutter run
    • On iOS Simulator or Device:

      flutter run
    • On Web:

      flutter run -d chrome

Troubleshooting

  • Missing Dependencies: Ensure all packages are installed by running flutter pub get.
  • Build Errors: Run flutter clean followed by flutter pub get and flutter run.
  • JSON Serialization Errors: Regenerate serialization code with flutter pub run build_runner build --delete-conflicting-outputs.

Generating JSON Serialization Code

The project uses json_serializable to handle JSON parsing automatically. To generate the necessary serialization code:

  1. Ensure Models Are Annotated

    Models should be annotated with @JsonSerializable() and include fromJson and toJson methods.

    Example: lib/data/models/user_model.dart

    dart
    import 'package:json_annotation/json_annotation.dart'; import 'package:flutter_getx_clean_architecture/domain/entities/user.dart';

    part 'user_model.g.dart';

    @JsonSerializable() class UserModel { final int id; final String name; final String username; final String email;

    UserModel({ required this.id, required this.name, required this.username, required this.email, });

    factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);

    Map<String, dynamic> toJson() => _$UserModelToJson(this);

    // Convert UserModel to User entity User toEntity() { return User( id: id, name: name, username: username, email: email, ); } }

  2. Run Build Runner

    Execute the following command to generate the .g.dart files containing serialization logic.

    flutter pub run build_runner build --delete-conflicting-outputs
  3. Regenerate on Changes

    Whenever you make changes to your models, rerun the build runner to update the serialization code.

    flutter pub run build_runner build --delete-conflicting-outputs
Clone this wiki locally