GetX Complete Guide with MVVM and Clean Architecture

GetX Complete Guide with MVVM and Clean Architecture

List of contents


GetX Introduction

GetX is a powerful Flutter framework with 3 main pillars:

1. State Management

  • Reactive and easy to use
  • High performance
  • Minimal boilerplate

2. Route Management (Navigation)

  • Navigation withoutcontext
  • Named routes
  • Middleware support

3. Dependency Management (Inject Dependencies)

  • Lazy loading
  • Auto dispose
  • Smart management

MVVM concept at GetX

What is MVVM?

MVVM = Model – View – ViewModel

VIEW (UI)

– Widget

– No business logic

– Only displays data

Observes (Obx/GetBuilder)

VIEWMODEL (Controller)

– Business Logic

– State Management

– Input Validation

– Communication with Repository

Retrieving/Saving Data

MODEL (Data Layer)

– Repository

– Provider (API, Database)

– Data Models

Mapping to GetX:

  • Model = models/, providers/, repositories/
  • View = views/ (Widget)
  • ViewModel = controllers/ (GetxController)

Structure Folder

Complete and Organized Structure

lib/

main.dart

app/

core/ # Code used throughout the app

utils/ # Utility functions

helpers.dart

validators.dart

values/ # Constants

colors.dart

strings.dart

text_styles.dart

widgets/ # Reusable widgets

custom_button.dart

loading_widget.dart

data/ # DATA LAYER (Model)

models/ # Data models

user_model.dart

product_model.dart

providers/ # API calls, local DB

api_provider.dart

local_storage_provider.dart

repositories/ # Bridge Controller Provider

user_repository.dart

product_repository.dart

modules/ # APPLICATION FEATURES

home/

bindings/

home_binding.dart

controllers/

home_controller.dart

views/

home_view.dart

widgets/

home_card.dart

login/

bindings/

login_binding.dart

controllers/

login_controller.dart

views/

login_view.dart

profile/

bindings/

profile_binding.dart

controllers/

profile_controller.dart

views/

profile_view.dart

routes/ # ROUTING

app_pages.dart # Definition of all routes

app_routes.dart # Constant house routes

pubspec.yaml

Structure Explanation:

1. /core – Shared Resources

  • utils/: Helper functions, validators, formatters
  • values/: Constants seperti colors, strings, API URLs
  • widgets/: Reusable widget for all apps

2. /data – Data Layer (MODEL)

  • models/: Class model for data (User, Product, etc.)
  • providers/: Place for API calls, database operations
  • repositories/: Abstraction between controller and provider

3. /modules – Feature Modules

  • Each feature has its own folder
  • Each module has:bindings, controllers, views

4. /routes – Navigation

  • Centralized routing configuration

Detailed Explanation of Each Layer

1. MODEL LAYER

a. Data Models (/data/models)

A model is a representation of data, usually from an API or database.

// lib/app/data/models/user_model.dart

class User {

final String id;

final String name;

final String email;

final String? avatar;

User({

required this.id,

required this.name,

required this.email,

this.avatar,

});

// From JSON to Object (parsing API response)

factory User.fromJson(Map<String, dynamic> json) {

return User(

id: json[‘id’] ?? ”,

name: json[‘name’] ?? ”,

email: json[’email’] ?? ”,

avatar: json[‘avatar’],

);

}

// From Object to JSON (to send to API)

Map<String, dynamic> toJson() {

return {

‘id’: id,

‘name’: name,

’email’: email,

‘avatar’: avatar,

};

}

// CopyWith for immutability

User copyWith({

String? id,

String? name,

String? email,

String? avatar,

}) {

return User(

id: id ?? this.id,

name: name ?? this.name,

email: email ?? this.email,

avatar: avatar ?? this.avatar,

);

}

}

Example of a Complex Model with Nested Objects:

// lib/app/data/models/product_model.dart

class Product {

final String id;

final String name;

final double price;

final Category category;

final List<String> images;

Product({

required this.id,

required this.name,

required this.price,

required this.category,

required this.images,

});

factory Product.fromJson(Map<String, dynamic> json) {

return Product(

id: json[‘id’],

name: json[‘name’],

price: (json[‘price’] as num).toDouble(),

category: Category.fromJson(json[‘category’]),

images: List<String>.from(json[‘images’] ?? []),

);

}

}

class Category {

final String id;

final String name;

Category({required this.id, required this.name});

factory Category.fromJson(Map<String, dynamic> json) {

return Category(

id: json[‘id’],

name: json[‘name’],

);

}

}

b. Providers (/data/providers)

The provider is responsible for communication with external data sources (API, Database, etc.).

// lib/app/data/providers/api_provider.dart

import ‘package:dio/dio.dart’;

class ApiProvider {

final Dio _dio = Dio(BaseOptions(

baseUrl: ‘https://api.example.com’,

connectTimeout: Duration(seconds: 5),

receiveTimeout: Duration(seconds: 3),

));

// GET request

Future<Response> get(String path) async {

try {

final response = await _dio.get(path);

return response;

} on DioException catch (e) {

throw _handleError(e);

}

}

// POST request

Future<Response> post(String path, Map<String, dynamic> data) async {

try {

final response = await _dio.post(path, data: data);

return response;

} on DioException catch (e) {

throw _handleError(e);

}

}

// PUT request

Future<Response> put(String path, Map<String, dynamic> data) async {

try {

final response = await _dio.put(path, data: data);

return response;

} on DioException catch (e) {

throw _handleError(e);

}

}

// DELETE request

Future<Response> delete(String path) async {

try {

final response = await _dio.delete(path);

return response;

} on DioException catch (e) {

throw _handleError(e);

}

}

// Error handling

Exception _handleError(DioException error) {

String errorMessage = ”;

switch (error.type) {

case DioExceptionType.connectionTimeout:

errorMessage = ‘Connection timeout’;

break;

case DioExceptionType.sendTimeout:

errorMessage = ‘Send timeout’;

break;

case DioExceptionType.receiveTimeout:

errorMessage = ‘Receive timeout’;

break;

case DioExceptionType.badResponse:

errorMessage = ‘Received invalid status code: ${error.response?.statusCode}’;

break;

case DioExceptionType.cancel:

errorMessage = ‘Request cancelled’;

break;

default:

errorMessage = ‘Connection error’;

}

return Exception(errorMessage);

}

}

Provider for Local Storage:

// lib/app/data/providers/local_storage_provider.dart

import ‘package:get_storage/get_storage.dart’;

class LocalStorageProvider {

final _storage = GetStorage();

// Save data

Future<void> write(String key, dynamic value) async {

await _storage.write(key, value);

}

// Read data

T? read<T>(String key) {

return _storage.read<T>(key);

}

// Delete data

Future<void> remove(String key) async {

await _storage.remove(key);

}

// Delete all data

Future<void> clearAll() async {

await _storage.erase();

}

}

c. Repositories (/data/repositories)

Repository isbridgebetween the Controller and the Provider. This abstraction eliminates the need for the controller to know the details of how data is retrieved.

// lib/app/data/repositories/user_repository.dart

import ‘../models/user_model.dart’;

import ‘../providers/api_provider.dart’;

class UserRepository {

final ApiProvider _apiProvider;

UserRepository(this._apiProvider);

// Get user by ID

Future<User> getUser(String userId) async {

try {

final response = await _apiProvider.get(‘/users/$userId’);

return User.fromJson(response.data);

} catch (e) {

throw Exception(‘Failed to fetch user: $e’);

}

}

// Get all users

Future<List<User>> getAllUsers() async {

try {

final response = await _apiProvider.get(‘/users’);

final List<dynamic> usersJson = response.data;

return usersJson.map((json) => User.fromJson(json)).toList();

} catch (e) {

throw Exception(‘Failed to fetch users: $e’);

}

}

// Update user

Future<User> updateUser(String userId, Map<String, dynamic> data) async {

try {

final response = await _apiProvider.put(‘/users/$userId’, data);

return User.fromJson(response.data);

} catch (e) {

throw Exception(‘Failed to update user: $e’);

}

}

// Delete user

Future<void> deleteUser(String userId) async {

try {

await _apiProvider.delete(‘/users/$userId’);

} catch (e) {

throw Exception(‘Failed to delete user: $e’);

}

}

}

Repository with Caching:

// lib/app/data/repositories/product_repository.dart

import ‘../models/product_model.dart’;

import ‘../providers/api_provider.dart’;

import ‘../providers/local_storage_provider.dart’;

class ProductRepository {

final ApiProvider _apiProvider;

final LocalStorageProvider _localStorageProvider;

ProductRepository(this._apiProvider, this._localStorageProvider);

Future<List<Product>> getProducts({bool forceRefresh = false}) async {

// Check cache first

if (!forceRefresh) {

final cachedProducts = _localStorageProvider.read<List>(‘products’);

if (cachedProducts != null && cachedProducts.isNotEmpty) {

return cachedProducts

.map((json) => Product.fromJson(json))

.toList();

}

}

// If cache is empty or force refresh, get from API

try {

final response = await _apiProvider.get(‘/products’);

final List<dynamic> productsJson = response.data;

// Simple cache

await _localStorageProvider.write(‘products’, productsJson);

return productsJson.map((json) => Product.fromJson(json)).toList();

} catch (e) {

throw Exception(‘Failed to fetch products: $e’);

}

}

}


2. VIEWMODEL LAYER (Controller)

Controller isbrainfrom the application. This is where all the business logic is located.

Basic Controller

// lib/app/modules/home/controllers/home_controller.dart

import ‘package:get/get.dart’;

import ‘../../../data/models/user_model.dart’;

import ‘../../../data/repositories/user_repository.dart’;

class HomeController extends GetxController {

final UserRepository repository;

HomeController(this.repository);

// Reactive variables

var isLoading = false.obs;

var users = <User>[].obs;

var errorMessage = ”.obs;

// Lifecycle: called when the controller is initialized

@override

void onInit() {

super.onInit();

fetchUsers();

}

// Lifecycle: called when the controller is ready to use

@override

void onReady() {

super.onReady();

print(‘HomeController is ready’);

}

// Lifecycle: called when the controller is deleted

@override

void onClose() {

print(‘HomeController is closed’);

super.onClose();

}

// Business Logic: Fetch users

Future<void> fetchUsers() async {

try {

isLoading.value = true;

errorMessage.value = ”;

final result = await repository.getAllUsers();

users.value = result;

} catch (e) {

errorMessage.value = e.toString();

Get.snackbar(

‘Error’,

‘Failed to load users’,

snackPosition: SnackPosition.BOTTOM,

);

} finally {

isLoading.value = false;

}

}

// Business Logic: Refresh

Future<void> refreshUsers() async {

await fetchUsers();

}

}

Controller with Form Validation

// lib/app/modules/login/controllers/login_controller.dart

import ‘package:flutter/material.dart’;

import ‘package:get/get.dart’;

import ‘../../../data/repositories/auth_repository.dart’;

import ‘../../../routes/app_routes.dart’;

class LoginController extends GetxController {

final AuthRepository repository;

LoginController(this.repository);

// Form controllers

final emailController = TextEditingController();

final passwordController = TextEditingController();

// Reactive variables

var isLoading = false.obs;

var isPasswordHidden = true.obs;

// Validation

String? validateEmail(String? value) {

if (value == null || value.isEmpty) {

return ‘Email cannot be empty’;

}

if (!GetUtils.isEmail(value)) {

return ‘Invalid email format’;

}

return null;

}

String? validatePassword(String? value) {

if (value == null || value.isEmpty) {

return ‘Password cannot be empty’;

}

if (value.length < 6) {

return ‘Password minimum 6 characters’;

}

return null;

}

// Toggle password visibility

void togglePasswordVisibility() {

isPasswordHidden.value = !isPasswordHidden.value;

}

// Login action

Future<void> login() async {

// Input validation

final emailError = validateEmail(emailController.text);

final passwordError = validatePassword(passwordController.text);

if (emailError != null || passwordError != null) {

Get.snackbar(‘Validation Error’, emailError ?? passwordError ?? ”);

return;

}

try {

isLoading.value = true;

await repository.login(

email: emailController.text,

password: passwordController.text,

);

// Navigate to home

Get.offAllNamed(Routes.HOME);

} catch (e) {

Get.snackbar(

‘Login Failed’,

e.toString(),

snackPosition: SnackPosition.BOTTOM,

);

} finally {

isLoading.value = false;

}

}

@override

void onClose() {

emailController.dispose();

passwordController.dispose();

super.onClose();

}

}

Controller dengan Workers (Side Effects)

// lib/app/modules/search/controllers/search_controller.dart

import ‘package:get/get.dart’;

import ‘../../../data/repositories/product_repository.dart’;

class SearchController extends GetxController {

final ProductRepository repository;

SearchController(this.repository);

var searchQuery = ”.obs;

var searchResults = [].obs;

var isSearching = false.obs;

@override

void onInit() {

super.onInit();

// Debounce: wait for the user to finish typing (1 second) then search

debounce(

searchQuery,

(_) => performSearch(),

time: Duration(seconds: 1),

);

// Ever: run every time searchQuery changes

ever(searchQuery, (_) {

print(‘Search query changed to: ${searchQuery.value}’);

});

// Once: run only once when first changed

once(searchQuery, (_) {

print(‘First search performed’);

});

}

void updateSearchQuery(String query) {

searchQuery.value = query;

}

Future<void> performSearch() async {

if (searchQuery.value.isEmpty) {

searchResults.clear();

return;

}

try {

isSearching.value = true;

final results = await repository.searchProducts(searchQuery.value);

searchResults.value = results;

} catch (e) {

Get.snackbar(‘Error’, ‘Search failed: $e’);

} finally {

isSearching.value = false;

}

}

}

Types of Workers in GetX:

  1. ever: Called every time the observable changes
  2. once: Called only once when the observable changes for the first time
  3. debounce: Waits for the user to finish performing an action (e.g. typing) before the action is executed
  4. interval: Ignore changes within a specified time interval

3. VIEW LAYER

View isUI/Widget. Its task is only to display data, there should be no business logic.

Basic View with GetView

GetView<T>is a widget that automatically has access to the type controllerT.

// lib/app/modules/home/views/home_view.dart

import ‘package:flutter/material.dart’;

import ‘package:get/get.dart’;

import ‘../controllers/home_controller.dart’;

class HomeView extends GetView<HomeController> {

const HomeView({Key? key}) : super(key: key);

@override

Widget build(BuildContext context) {

return Scaffold(

appBar: AppBar(

title: Text(‘Home’),

actions: [

IconButton(

icon: Icon(Icons.refresh),

onPressed: controller.refreshUsers,

),

],

),

body: Obx(() {

// Loading state

if (controller.isLoading.value) {

return Center(child: CircularProgressIndicator());

}

// Error state

if (controller.errorMessage.value.isNotEmpty) {

return Center(

child: Column(

mainAxisAlignment: MainAxisAlignment.center,

children: [

Text(controller.errorMessage.value),

SizedBox(height: 16),

ElevatedButton(

onPressed: controller.fetchUsers,

child: Text(‘Retry’),

),

],

),

);

}

// Empty state

if (controller.users.isEmpty) {

return Center(child: Text(‘No users found’));

}

// Success state

return ListView.builder(

itemCount: controller.users.length,

itemBuilder: (context, index) {

final user = controller.users[index];

return ListTile(

leading: CircleAvatar(

backgroundImage: NetworkImage(user.avatar ?? ”),

),

title: Text(user.name),

subtitle: Text(user.email),

onTap: () {

Get.toNamed(Routes.PROFILE, arguments: user.id);

},

);

},

);

}),

);

}

}

View with Form

// lib/app/modules/login/views/login_view.dart

import ‘package:flutter/material.dart’;

import ‘package:get/get.dart’;

import ‘../controllers/login_controller.dart’;

class LoginView extends GetView<LoginController> {

const LoginView({Key? key}) : super(key: key);

@override

Widget build(BuildContext context) {

return Scaffold(

body: SafeArea(

child: Padding(

padding: EdgeInsets.all(24),

child: Column(

mainAxisAlignment: MainAxisAlignment.center,

children: [

// Logo

FlutterLogo(size: 100),

SizedBox(height: 48),

// Email field

TextField(

controller: controller.emailController,

decoration: InputDecoration(

labelText: ‘Email’,

border: OutlineInputBorder(),

prefixIcon: Icon(Icons.email),

),

keyboardType: TextInputType.emailAddress,

),

SizedBox(height: 16),

// Password field

Obx(() => TextField(

controller: controller.passwordController,

obscureText: controller.isPasswordHidden.value,

decoration: InputDecoration(

labelText: ‘Password’,

border: OutlineInputBorder(),

prefixIcon: Icon(Icons.lock),

suffixIcon: IconButton(

icon: Icon(

controller.isPasswordHidden.value

? Icons.visibility_off

: Icons.visibility,

),

onPressed: controller.togglePasswordVisibility,

),

),

)),

SizedBox(height: 24),

// Login button

Obx(() => SizedBox(

width: double.infinity,

height: 50,

child: ElevatedButton(

onPressed: controller.isLoading.value

? null

: controller.login,

child: controller.isLoading.value

? CircularProgressIndicator(color: Colors.white)

: Text(‘Login’),

),

)),

],

),

),

),

);

}

}

Reactive State: Obx vs GetBuilder

1. Obx – Untuk reactive variables (.obs)

Obx(() => Text(‘Count: ${controller.count.value}’))

2. GetBuilder- For manual updates (more performance for complex data)

GetBuilder<HomeController>(

builder: (controller) => Text(‘Count: ${controller.count}’),

)

3. GetX Widget- Combination of dependency injection + reactive

GetX<HomeController>(

init: HomeController(),

builder: (controller) => Text(‘Count: ${controller.count.value}’),

)


4. BINDINGS (Dependency Injection)

Bindings connect the controller to its dependencies and perform lazy initialization.

Basic Binding

// lib/app/modules/home/bindings/home_binding.dart

import ‘package:get/get.dart’;

import ‘../../../data/providers/api_provider.dart’;

import ‘../../../data/repositories/user_repository.dart’;

import ‘../controllers/home_controller.dart’;

class HomeBinding extends Bindings {

@override

void dependencies() {

// Lazy initialization: only created when needed

Get.lazyPut<ApiProvider>(() => ApiProvider());

Get.lazyPut<UserRepository>(() => UserRepository(Get.find()));

Get.lazyPut<HomeController>(() => HomeController(Get.find()));

}

}

Types of Dependency Injection in GetX:

// 1. lazyPut – Created when first needed (most frequently used)

Get.lazyPut(() => HomeController());

// 2. put – Created immediately, even if not used yet

Get.put(HomeController());

// 3. putAsync – For async initialization

Get.putAsync<SharedPreferences>(() async {

return await SharedPreferences.getInstance();

});

// 4. create – Creates a new instance every time Get.find() is called

Get.create(() => HomeController());

Binding with Multiple Dependencies

// lib/app/modules/profile/bindings/profile_binding.dart

import ‘package:get/get.dart’;

import ‘../../../data/providers/api_provider.dart’;

import ‘../../../data/providers/local_storage_provider.dart’;

import ‘../../../data/repositories/user_repository.dart’;

import ‘../../../data/repositories/auth_repository.dart’;

import ‘../controllers/profile_controller.dart’;

class ProfileBinding extends Bindings {

@override

void dependencies() {

// Providers

Get.lazyPut<ApiProvider>(() => ApiProvider());

Get.lazyPut<LocalStorageProvider>(() => LocalStorageProvider());

// Repositories

Get.lazyPut<UserRepository>(

() => UserRepository(Get.find()),

);

Get.lazyPut<AuthRepository>(

() => AuthRepository(Get.find(), Get.find()),

);

// Controller

Get.lazyPut<ProfileController>(

() => ProfileController(Get.find(), Get.find()),

);

}

}


5. ROUTING

a. Routes Constants

// lib/app/routes/app_routes.dart

abstract class Routes {

static const SPLASH = ‘/splash’;

static const LOGIN = ‘/login’;

static const REGISTER = ‘/register’;

static const HOME = ‘/home’;

static const PROFILE = ‘/profile’;

static const SETTINGS = ‘/settings’;

static const PRODUCT_DETAIL = ‘/product-detail’;

}

b. Pages Configuration

// lib/app/routes/app_pages.dart

import ‘package:get/get.dart’;

import ‘../modules/home/bindings/home_binding.dart’;

import ‘../modules/home/views/home_view.dart’;

import ‘../modules/login/bindings/login_binding.dart’;

import ‘../modules/login/views/login_view.dart’;

import ‘../modules/profile/bindings/profile_binding.dart’;

import ‘../modules/profile/views/profile_view.dart’;

import ‘app_routes.dart’;

class AppPages {

static const INITIAL = Routes.LOGIN;

static final routes = [

GetPage(

name: Routes.LOGIN,

page: () => LoginView(),

binding: LoginBinding(),

),

GetPage(

name: Routes.HOME,

page: () => HomeView(),

binding: HomeBinding(),

transition: Transition.fadeIn,

),

GetPage(

name: Routes.PROFILE,

page: () => ProfileView(),

binding: ProfileBinding(),

transition: Transition.rightToLeft,

),

];

}

c. Navigation Methods

// Navigate to new page

Get.to(() => NextPage());

Get.toNamed(Routes.HOME);

// Navigation with arguments

Get.toNamed(Routes.PROFILE, arguments: {‘userId’: ‘123’});

// In the destination controller, accept the arguments:

final userId = Get.arguments[‘userId’];

// Navigate and delete previous page

Get.off(() => HomePage());

Get.offNamed(Routes.HOME);

// Navigate and clear all previous pages (clear stack)

Get.offAll(() => HomePage());

Get.offAllNamed(Routes.HOME);

// Back to previous page

Get.back();

// Return with data

Get.back(result: {‘success’: true});

// Return until a certain condition is met

Get.until((route) => route.settings.name == Routes.HOME);

d. Middleware

Middleware is useful for guard routes (e.g. login check).

// lib/app/middlewares/auth_middleware.dart

import ‘package:flutter/material.dart’;

import ‘package:get/get.dart’;

import ‘../routes/app_routes.dart’;

class AuthMiddleware extends GetMiddleware {

@override

int? get priority => 1;

@override

RouteSettings? redirect(String? route) {

// Check if the user is logged in

final isLoggedIn = Get.find<AuthService>().isLoggedIn;

if (!isLoggedIn) {

return RouteSettings(name: Routes.LOGIN);

}

return null; // null = continue to the requested route

}

}

// Use in app_pages.dart:

GetPage(

name: Routes.HOME,

page: () => HomeView(),

binding: HomeBinding(),

middlewares: [AuthMiddleware()],

),


Complete Implementation

Case Study: Todo App

1. Model

// lib/app/data/models/todo_model.dart

class Todo {

final String id;

final String title;

final String description;

final bool isCompleted;

final DateTime createdAt;

Todo({

required this.id,

required this.title,

required this.description,

this.isCompleted = false,

required this.createdAt,

});

factory Todo.fromJson(Map<String, dynamic> json) {

return Todo(

id: json[‘id’],

title: json[‘title’],

description: json[‘description’] ?? ”,

isCompleted: json[‘isCompleted’] ?? false,

createdAt: DateTime.parse(json[‘createdAt’]),

);

}

Map<String, dynamic> toJson() {

return {

‘id’: id,

‘title’: title,

‘description’: description,

‘isCompleted’: isCompleted,

‘createdAt’: createdAt.toIso8601String(),

};

}

Todo copyWith({

String? id,

String? title,

String? description,

bool? isCompleted,

DateTime? createdAt,

}) {

return Todo(

id: id ?? this.id,

title: title ?? this.title,

description: description ?? this.description,

isCompleted: isCompleted ?? this.isCompleted,

createdAt: createdAt ?? this.createdAt,

);

}

}

2. Repository

// lib/app/data/repositories/todo_repository.dart

import ‘../models/todo_model.dart’;

import ‘../providers/api_provider.dart’;

class TodoRepository {

final ApiProvider _apiProvider;

TodoRepository(this._apiProvider);

Future<List<Todo>> getAllTodos() async {

try {

final response = await _apiProvider.get(‘/todos’);

final List<dynamic> todosJson = response.data;

return allJson.map((json) => All.fromJson(json)).toList();

} catch (e) {

throw Exception(‘Failed to fetch todos: $e’);

}

}

Future<Todo> createTodo(Todo todo) async {

try {

final response = await _apiProvider.post(‘/todos’, todo.toJson());

return Todo.fromJson(response.data);

} catch (e) {

throw Exception(‘Failed to create todo: $e’);

}

}

Future<Todo> updateTodo(String id, Todo todo) async {

try {

final response = await _apiProvider.put(‘/todos/$id’, todo.toJson());

return Todo.fromJson(response.data);

} catch (e) {

throw Exception(‘Failed to update todo: $e’);

}

}

Future<void> deleteTodo(String id) async {

try {

await _apiProvider.delete(‘/todos/$id’);

} catch (e) {

throw Exception(‘Failed to delete todo: $e’);

}

}

}

3. Controller

// lib/app/modules/todo/controllers/todo_controller.dart

import ‘package:get/get.dart’;

import ‘../../../data/models/todo_model.dart’;

import ‘../../../data/repositories/todo_repository.dart’;

class TodoController extends GetxController {

final TodoRepository repository;

TodoController(this.repository);

var todos = <Todo>[].obs;

var isLoading = false.obs;

var filter = TodoFilter.all.obs;

@override

void onInit() {

super.onInit();

fetchTodos();

}

// Get filtered todos

List<Todo> get filteredTodos {

switch (filter.value) {

case TodoFilter.completed:

return todos.where((todo) => todo.isCompleted).toList();

case TodoFilter.active:

return todos.where((all) => !all.isCompleted).toList();

default:

return all;

}

}

// Get count

int get completedCount => todos.where((t) => t.isCompleted).length;

int get activeCount => todos.where((t) => !t.isCompleted).length;

Future<void> fetchTodos() async {

try {

isLoading.value = true;

final result = await repository.getAllTodos();

everyone.value = result;

} catch (e) {

Get.snackbar(‘Error’, ‘Failed to fetch todos’);

} finally {

isLoading.value = false;

}

}

Future<void> addTodo(String title, String description) async {

try {

final newTodo = Todo(

id: DateTime.now().toString(),

title: title,

description: description,

createdAt: DateTime.now(),

);

final created = await repository.createTodo(newTodo);

todos.add(created);

Get.snackbar(‘Success’, ‘Todo added successfully’);

} catch (e) {

Get.snackbar(‘Error’, ‘Failed to add todo’);

}

}

Future<void> toggleTodo(Todo todo) async {

try {

final updated = todo.copyWith(isCompleted: !todo.isCompleted);

await repository.updateAll(all.id, updated);

final index = todos.indexWhere((t) => t.id == todo.id);

todos[index] = updated;

} catch (e) {

Get.snackbar(‘Error’, ‘Failed to update todo’);

}

}

Future<void> deleteTodo(String id) async {

try {

await repository.deleteTodo(id);

everyone.removeWhere((everything) => everything.id == id);

Get.snackbar(‘Success’, ‘Todo deleted’);

} catch (e) {

Get.snackbar(‘Error’, ‘Failed to delete todo’);

}

}

void setFilter(TodoFilter newFilter) {

filter.value = newFilter;

}

}

enum TodoFilter { all, active, completed }

4. View

// lib/app/modules/todo/views/todo_view.dart

import ‘package:flutter/material.dart’;

import ‘package:get/get.dart’;

import ‘../controllers/todo_controller.dart’;

class TodoView extends GetView<TodoController> {

const TodoView({Key? key}) : super(key: key);

@override

Widget build(BuildContext context) {

return Scaffold(

appBar: AppBar(

title: Text(‘My Todos’),

bottom: PreferredSize(

preferredSize: Size.fromHeight(50),

child: _buildFilterTabs(),

),

),

body: Obx(() {

if (controller.isLoading.value) {

return Center(child: CircularProgressIndicator());

}

if (controller.filteredTodos.isEmpty) {

return Center(child: Text(‘No todos found’));

}

return ListView.builder(

itemCount: controller.filteredTodos.length,

itemBuilder: (context, index) {

final todo = controller.filteredTodos[index];

return _buildTodoItem(todo);

},

);

}),

floatingActionButton: FloatingActionButton(

onPressed: _showAddTodoDialog,

child: Icon(Icons.add),

),

bottomNavigationBar: _buildBottomBar(),

);

}

Widget _buildFilterTabs() {

return Obx(() => Row(

mainAxisAlignment: MainAxisAlignment.spaceEvenly,

children: [

_buildFilterChip(‘All’, TodoFilter.all),

_buildFilterChip(‘Active’, TodoFilter.active),

_buildFilterChip(‘Completed’, TodoFilter.completed),

],

));

}

Widget _buildFilterChip(String label, TodoFilter filter) {

final isSelected = controller.filter.value == filter;

return ChoiceChip(

label: Text(label),

selected: isSelected,

onSelected: (_) => controller.setFilter(filter),

);

}

Widget _buildTodoItem(Todo todo) {

return Dismissible(

key: Key(todo.id),

background: Container(

color: Colors.red,

alignment: Alignment.centerRight,

padding: EdgeInsets.only(right: 16),

child: Icon(Icons.delete, color: Colors.white),

),

direction: DismissDirection.endToStart,

onDismissed: (_) => controller.deleteTodo(todo.id),

child: ListTile(

leading: Checkbox(

value: todo.isCompleted,

onChanged: (_) => controller.toggleTodo(todo),

),

title: Text(

todo.title,

style: TextStyle(

decoration: todo.isCompleted

? TextDecoration.lineThrough

: TextDecoration.none,

),

),

subtitle: Text(todo.description),

),

);

}

Widget _buildBottomBar() {

return Obx(() => Container(

padding: EdgeInsets.all(16),

child: Text(

‘${controller.activeCount} active ${controller.completedCount} completed’,

textAlign: TextAlign.center,

),

));

}

void _showAddTodoDialog() {

final titleController = TextEditingController();

final descController = TextEditingController();

Get.dialog(

AlertDialog(

title: Text(‘Add New Todo’),

content: Column(

mainAxisSize: MainAxisSize.min,

children: [

TextField(

controller: titleController,

decoration: InputDecoration(labelText: ‘Title’),

),

SizedBox(height: 8),

TextField(

controller: descController,

decoration: InputDecoration(labelText: ‘Description’),

),

],

),

actions: [

TextButton(

onPressed: () => Get.back(),

child: Text(‘Cancel’),

),

ElevatedButton(

onPressed: () {

if (titleController.text.isNotEmpty) {

controller.addTodo(

titleController.text,

descController.text,

);

Get.back();

}

},

child: Text(‘Add’),

),

],

),

);

}

}

5. Binding

// lib/app/modules/todo/bindings/todo_binding.dart

import ‘package:get/get.dart’;

import ‘../../../data/providers/api_provider.dart’;

import ‘../../../data/repositories/todo_repository.dart’;

import ‘../controllers/todo_controller.dart’;

class TodoBinding extends Bindings {

@override

void dependencies() {

Get.lazyPut<ApiProvider>(() => ApiProvider());

Get.lazyPut<TodoRepository>(() => TodoRepository(Get.find()));

Get.lazyPut<TodoController>(() => TodoController(Get.find()));

}

}

6. Main.dart

// lib/main.dart

import ‘package:flutter/material.dart’;

import ‘package:get/get.dart’;

import ‘app/routes/app_pages.dart’;

void main() {

runApp(MyApp());

}

class MyApp extends StatelessWidget {

@override

Widget build(BuildContext context) {

return GetMaterialApp(

title: ‘Every App’,

theme: ThemeData(

primarySwatch: Colors.blue,

useMaterial3: true,

),

initialRoute: AppPages.INITIAL,

getPages: AppPages.routes,

debugShowCheckedModeBanner: false,

);

}

}


Best Practices

1. Naming Conventions

// File names: snake_case

user_model.dart

home_controller.dart

api_provider.dart

// Class names: PascalCase

class UserModel {}

class HomeController {}

// Variables & methods: camelCase

var userName = ”;

void fetchUsers() {}

// Constants: UPPER_SNAKE_CASE

const API_BASE_URL = ‘https://api.example.com’;

// Private members: prefix with _

var _isLoading = false;

void _handleError() {}

2. Reactive Variables

// GOOD: Use .obs for primitive data

var count = 0.obs;

var name = ”.obs;

var isLoading = false.obs;

// GOOD: Use Rx<Type> for objects and nullables

var user = Rx<User?>(null);

var selectedDate = Rx<DateTime>(DateTime.now());

// GOOD: Use RxList, RxMap for collections

var items = <String>[].obs;

var settings = <String, dynamic>{}.obs;

// BAD: Don’t use .obs for non-reactive data

3. Controller Lifecycle

class MyController extends GetxController {

// 1. Constructor

MyController(this.repository);

// 2. onInit – Initial setup

@override

void onInit() {

super.onInit();

fetchData(); // Load data for the first time

setupListeners(); // Setup workers

}

// 3. onReady – After the widget is ready

@override

void onReady() {

super.onReady();

// Action after UI is ready

}

// 4. onClose – Cleanup

@override

void onClose() {

// Dispose controllers, cancel subscriptions

textController.dispose();

super.onClose();

}

}

4. Error Handling Pattern

Future<void> fetchData() async {

try {

isLoading.value = true;

errorMessage.value = ”;

final result = await repository.getData();

data.value = result;

} on NetworkException catch (e) {

errorMessage.value = ‘Network error: ${e.message}’;

Get.snackbar(‘Network Error’, e.message);

} on ValidationException catch (e) {

errorMessage.value = ‘Validation error: ${e.message}’;

} catch (e) {

errorMessage.value = ‘Unexpected error: $e’;

Get.snackbar(‘Error’, ‘Something went wrong’);

} finally {

isLoading.value = false;

}

}

5. Separation of Concerns

// BAD: Business logic di View

class HomeView extends StatelessWidget {

@override

Widget build(BuildContext context) {

return ElevatedButton(

onPressed: () async {

// Don’t be like this!

final response = await http.get(‘…’);

final data = jsonDecode(response.body);

},

child: Text(‘Fetch’),

);

}

}

// GOOD: Business logic di Controller

class HomeController extends GetxController {

Future<void> fetchData() async {

final response = await repository.getData();

// Process data

}

}

class HomeView extends GetView<HomeController> {

@override

Widget build(BuildContext context) {

return ElevatedButton(

onPressed: controller.fetchData,

child: Text(‘Fetch’),

);

}

}

6. Use GetView When Possible

// GOOD: Use GetView if you only need 1 controller

class HomeView extends GetView<HomeController> {

@override

Widget build(BuildContext context) {

return Text(controller.title); // Directly access the controller

}

}

// If you need multiple controllers:

class ComplexView extends StatelessWidget {

@override

Widget build(BuildContext context) {

final homeCtrl = Get.find<HomeController>();

final authCtrl = Get.find<AuthController>();

return Column(

children: [

Text(homeCtrl.title),

Text(authCtrl.userName),

],

);

}

}

7. Lazy Loading Dependencies

// GOOD: LazyPut – only created when needed

class HomeBinding extends Bindings {

@override

void dependencies() {

Get.lazyPut(() => HomeController());

}

}

// AVOID: Put – created immediately even if not used yet

class HomeBinding extends Bindings {

@override

void dependencies() {

Get.put(HomeController()); // Created directly

}

}

8. Reusable Widgets

// lib/app/core/widgets/custom_button.dart

class CustomButton extends StatelessWidget {

final String text;

final VoidCallback onPressed;

final bool isLoading;

const CustomButton({

required this.text,

required this.onPressed,

this.isLoading = false,

});

@override

Widget build(BuildContext context) {

return ElevatedButton(

onPressed: isLoading ? null : onPressed,

child: isLoading

? CircularProgressIndicator()

: Text(text),

);

}

}


Tips & Tricks

1. Snackbar, Dialog, BottomSheet without Context

Snack bar

Get.snackbar(

‘Title’,

‘Message’,

snackPosition: SnackPosition.BOTTOM,

backgroundColor: Colors.red,

colorText: Colors.white,

duration: Duration(seconds: 3),

);

// Dialog

Get.defaultDialog(

title: ‘Alert’,

middleText: ‘Are you sure?’,

onConfirm: () => Get.back(),

onCancel: () => Get.back(),

);

// Custom Dialog

Get.dialog(

AlertDialog(

title: Text(‘Custom Dialog’),

content: Text(‘This is a custom dialog’),

actions: [

TextButton(

onPressed: () => Get.back(),

child: Text(‘OK’),

),

],

),

);

// Bottom Sheet

Get.bottomSheet(

Container(

color: Colors.white,

child: Column(

children: [

ListTile(

leading: Icon(Icons.camera),

title: Text(‘Camera’),

onTap: () => Get.back(),

),

ListTile(

leading: Icon(Icons.photo),

title: Text(‘Gallery’),

onTap: () => Get.back(),

),

],

),

),

);

2. GetUtils Helper Functions

// Email validation

if (GetUtils.isEmail(email)) {

// Valid email

}

// Phone validation

if (GetUtils.isPhoneNumber(phone)) {

// Valid phone

}

// URL validation

if (GetUtils.isURL(url)) {

// Valid URL

}

// Null check

if (GetUtils.isNull(value)) {

// Is null

}

// Number check

if (GetUtils.isNum(value)) {

// Is number

}

3. Platform Checks

if (GetPlatform.isAndroid) {

// Android specific code

}

if (GetPlatform.isIOS) {

// iOS specific code

}

if (GetPlatform.isWeb) {

// Web specific code

}

if (GetPlatform.isMobile) {

// Mobile (Android or iOS)

}

4. Internationalization (i18n)

// lib/app/translations/app_translations.dart

class AppTranslations extends Translations {

@override

Map<String, Map<String, String>> get keys => {

‘en_US’: {

‘hello’: ‘Hello’,

‘welcome’: ‘Welcome @name’,

},

‘id_ID’: {

‘hello’: ‘Halo’,

‘welcome’: ‘Welcome @name’,

},

};

}

// In main.dart

GetMaterialApp(

translations: AppTranslations(),

locale: Locale(‘id’, ‘ID’),

fallbackLocale: Locale(‘in’, ‘US’),

);

// Use in code

Text(‘hello’.tr); // Output: Halo

Text(‘welcome’.trParams({‘name’: ‘John’})); // Output: Welcome John

// Change language

Get.updateLocale(Locale(‘en’, ‘US’));

5. Theme Management

// Change theme

Get.changeTheme(ThemeData.dark());

Get.changeTheme(ThemeData.light());

// Cek current theme

if (Get.isDarkMode) {

// Dark mode active

}

6. Smart Refresh

class MyController extends GetxController {

Future<void> refreshData() async {

await fetchData();

update([‘my-list’]); // Update hanya widget dengan id ‘my-list’

}

}

// In view

GetBuilder<MyController>(

id: ‘my-list’,

builder: (controller) => ListView(…),

)

7. StateMixin for Loading States

class MyController extends GetxController with StateMixin<List<User>> {

@override

void onInit() {

super.onInit();

fetchUsers();

}

Future<void> fetchUsers() async {

change(null, status: RxStatus.loading());

try {

final users = await repository.getUsers();

change(users, status: RxStatus.success());

} catch (e) {

change(null, status: RxStatus.error(‘Failed to load’));

}

}

}

// In view

controller.obx(

(users) => ListView.builder(…), // Success

onLoading: CircularProgressIndicator(),

onError: (error) => Text(error ?? ‘Error’),

onEmpty: Text(‘No data’),

)

8. Global Controllers

// For controllers used in multiple places

class AppController extends GetxController {

var theme = ThemeMode.light.obs;

var locale = Locale(‘en’, ‘US’).obs;

void toggleTheme() {

theme.value = theme.value == ThemeMode.light

? ThemeMode.dark

: ThemeMode.light;

Get.changeThemeMode(theme.value);

}

}

// In main.dart

void main() {

Get.put(AppController(), permanent: true); // permanent = not auto dispose

runApp(MyApp());

}

// Access from anywhere

final appCtrl = Get.find<AppController>();

appCtrl.toggleTheme();


Conclusion

GetX with MVVM provides:

  1. Clean Architecture- Clear separation between UI, Logic, and Data
  2. Testability- Easy to test because it is separate
  3. Scalability- Easy to develop for large projects
  4. Maintainability- Code is easy for the team to maintain and understand

Key to Success:

  • Consistent with folder structure
  • Separate concerns (View, Controller, Repository)
  • Use Binding for dependency injection
  • Take advantage of GetX’s reactive programming
  • Follow best practices
  • Well documented code

Resources:


Made with for Flutter Developers

Requirements:

WRITE MY PAPER