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:
- ever: Called every time the observable changes
- once: Called only once when the observable changes for the first time
- debounce: Waits for the user to finish performing an action (e.g. typing) before the action is executed
- 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:
- Clean Architecture- Clear separation between UI, Logic, and Data
- Testability- Easy to test because it is separate
- Scalability- Easy to develop for large projects
- 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