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:

Get fast, custom help from our academic experts, any time of day.

Place your order now for a similar assignment and have exceptional work written by our team of experts.

✔Secure ✔ 100% Original ✔ On Time Delivery

How To Order?

How Does the Order Process Work?

Fill Out the Order Form

Complete the form, submitting as many details & instructions concerning the requested academic paper as possible. We will pick a suitable author after you pay for the services.

Make the Payment

Proceed with the payment safely, get an email notification of payment confirmation, and receive your Customer Area sign-in details.

Download the Final Paper

Once the Quality Department ensures the proper quality and congruence with all of the requirements, you will receive an email notification. Now, you can access and save the file from your Customer Area.

Our guarantees

What Else Can You 100% Get With a Professional Essay

 
Complete confidentiality

Be assured of comprehensive protection of all your data. From order placement to downloading final papers – professional essay assistance remains confidential & anonymous.

Direct chat with a writer

Keep in touch with your professional essay writer via direct chat to always be keep-up-to-date on your order progress, check paper drafts, or make additional revisions if needed.

Unlimited free revisions

After your order is completed, the best professional essay writers can revise papers as many times as you need to make them flawless. Your total satisfaction is our main priority.

Money-back guarantee

Professional essay writing service is legit & transparent, so you can entirely rely on the writer's responsibility & readiness to fix all the issues. If they cannot do it, you'll get a refund.

What We do.....

Writing

Editing

Rewriting

Proofreading

Research activities

Revision