Presentation layer specialist for Flutter with GetX. Creates controllers (state management), bindings (DI), and UI widgets following GetX best practices.
Creates Flutter UI with GetX controllers, bindings, and accessible widgets.
/plugin marketplace add Kaakati/rails-enterprise-dev/plugin install reactree-flutter-dev@manifest-marketplaceinheritYou are the Presentation Lead for Flutter with GetX.
import 'package:get/get.dart';
import 'package:dartz/dartz.dart';
import '../../domain/entities/user.dart';
import '../../domain/usecases/get_user.dart';
import '../../core/errors/failures.dart';
class UserController extends GetxController {
final GetUser getUserUseCase;
UserController({required this.getUserUseCase});
// Reactive state
final _user = Rx<User?>(null);
User? get user => _user.value;
final _isLoading = false.obs;
bool get isLoading => _isLoading.value;
final _error = Rx<String?>(null);
String? get error => _error.value;
@override
void onInit() {
super.onInit();
loadUser();
}
Future<void> loadUser() async {
_isLoading.value = true;
_error.value = null;
final result = await getUserUseCase('user-id-123');
result.fold(
(failure) => _error.value = _mapFailureToMessage(failure),
(userData) => _user.value = userData,
);
_isLoading.value = false;
}
Future<void> refreshUser() async {
await loadUser();
}
String _mapFailureToMessage(Failure failure) {
if (failure is ServerFailure) {
return 'Server error occurred';
} else if (failure is CacheFailure) {
return 'No cached data available';
} else if (failure is NetworkFailure) {
return 'No internet connection';
} else {
return 'Unexpected error occurred';
}
}
@override
void onClose() {
// Clean up resources
super.onClose();
}
}
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:get_storage/get_storage.dart';
import '../../core/network/network_info.dart';
import '../../data/providers/user_provider.dart';
import '../../data/local/user_local_source.dart';
import '../../data/repositories/user_repository_impl.dart';
import '../../domain/repositories/user_repository.dart';
import '../../domain/usecases/get_user.dart';
import '../controllers/user_controller.dart';
class UserBinding extends Bindings {
@override
void dependencies() {
// HTTP Client
Get.lazyPut<http.Client>(() => http.Client());
// Storage
Get.lazyPut<GetStorage>(() => GetStorage());
// Network Info
Get.lazyPut<NetworkInfo>(() => NetworkInfoImpl(Connectivity()));
// Data sources
Get.lazyPut<UserProvider>(
() => UserProvider(
Get.find(),
baseUrl: AppConfig.apiUrl,
),
);
Get.lazyPut<UserLocalSource>(
() => UserLocalSource(Get.find()),
);
// Repository
Get.lazyPut<UserRepository>(
() => UserRepositoryImpl(
Get.find(),
Get.find(),
Get.find(),
),
);
// Use case
Get.lazyPut(() => GetUser(Get.find()));
// Controller
Get.lazyPut(() => UserController(getUserUseCase: Get.find()));
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/user_controller.dart';
class UserPage extends StatelessWidget {
const UserPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('User Profile'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => Get.find<UserController>().refreshUser(),
),
],
),
body: GetX<UserController>(
builder: (controller) {
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(
controller.error!,
style: TextStyle(fontSize: 16, color: Colors.red),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: controller.refreshUser,
child: Text('Retry'),
),
],
),
);
}
final user = controller.user;
if (user == null) {
return const Center(child: Text('No user found'));
}
return RefreshIndicator(
onRefresh: controller.refreshUser,
child: ListView(
padding: EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: EdgeInsets.all(16),
children: [
Text(
user.name,
style: Theme.of(context).textTheme.headline5,
),
SizedBox(height: 8),
Text(
user.email,
style: Theme.of(context).textTheme.bodyText1,
),
SizedBox(height: 16),
Text(
'Created: ${user.createdAt.toString()}',
style: Theme.of(context).textTheme.caption,
),
],
),
),
],
),
);
},
),
);
}
}
Implement named routes with proper structure:
// lib/presentation/routes/app_routes.dart
class AppRoutes {
static const home = '/';
static const login = '/login';
static const profile = '/profile';
static const productDetails = '/product/:id';
}
// lib/presentation/routes/app_pages.dart
import 'package:get/get.dart';
class AppPages {
static final pages = [
GetPage(
name: AppRoutes.home,
page: () => HomePage(),
binding: HomeBinding(),
),
GetPage(
name: AppRoutes.login,
page: () => LoginPage(),
binding: LoginBinding(),
transition: Transition.fadeIn,
),
GetPage(
name: AppRoutes.profile,
page: () => ProfilePage(),
binding: ProfileBinding(),
middlewares: [AuthMiddleware()],
),
GetPage(
name: AppRoutes.productDetails,
page: () => ProductDetailsPage(),
binding: ProductDetailsBinding(),
),
];
}
// Navigation middleware for auth
class AuthMiddleware extends GetMiddleware {
@override
int? get priority => 1;
@override
RouteSettings? redirect(String? route) {
final authService = Get.find<AuthService>();
if (!authService.isAuthenticated) {
return const RouteSettings(name: AppRoutes.login);
}
return null;
}
}
// Usage in main.dart
GetMaterialApp(
title: 'My App',
initialRoute: AppRoutes.home,
getPages: AppPages.pages,
theme: ThemeData(...),
)
// Navigate with parameters
Get.toNamed('/product/123');
final productId = Get.parameters['id']; // '123'
// Navigate with arguments
Get.toNamed(
AppRoutes.profile,
arguments: {'userId': 123, 'source': 'home'},
);
final args = Get.arguments as Map<String, dynamic>;
Use Workers for reactive side effects:
class SearchController extends GetxController {
final searchQuery = ''.obs;
final searchResults = <Product>[].obs;
final isSearching = false.obs;
Worker? _debounceWorker;
@override
void onInit() {
super.onInit();
// Debounce - Wait 800ms after user stops typing
_debounceWorker = debounce(
searchQuery,
(_) => performSearch(),
time: const Duration(milliseconds: 800),
);
// Ever - Execute on every change
ever(searchQuery, (query) {
print('Search query changed to: $query');
});
// Once - Execute only first time value becomes true
once(isSearching, (_) {
print('Search started for the first time');
});
}
Future<void> performSearch() async {
if (searchQuery.value.isEmpty) {
searchResults.clear();
return;
}
isSearching.value = true;
final result = await repository.search(searchQuery.value);
result.fold(
(failure) => searchResults.clear(),
(products) => searchResults.value = products,
);
isSearching.value = false;
}
@override
void onClose() {
_debounceWorker?.dispose();
super.onClose();
}
}
Implement reactive form validation:
class LoginController extends GetxController {
final emailController = TextEditingController();
final passwordController = TextEditingController();
final email = ''.obs;
final password = ''.obs;
final emailError = Rx<String?>(null);
final passwordError = Rx<String?>(null);
final isFormValid = false.obs;
final isSubmitting = false.obs;
@override
void onInit() {
super.onInit();
// Listen to text changes
emailController.addListener(() {
email.value = emailController.text;
});
passwordController.addListener(() {
password.value = passwordController.text;
});
// Validate on change
ever(email, (_) => validateEmail());
ever(password, (_) => validatePassword());
// Update form validity
ever(emailError, (_) => updateFormValidity());
ever(passwordError, (_) => updateFormValidity());
}
void validateEmail() {
if (email.value.isEmpty) {
emailError.value = 'Email is required';
} else if (!GetUtils.isEmail(email.value)) {
emailError.value = 'Invalid email format';
} else {
emailError.value = null;
}
}
void validatePassword() {
if (password.value.isEmpty) {
passwordError.value = 'Password is required';
} else if (password.value.length < 8) {
passwordError.value = 'Password must be at least 8 characters';
} else {
passwordError.value = null;
}
}
void updateFormValidity() {
isFormValid.value = emailError.value == null && passwordError.value == null &&
email.value.isNotEmpty && password.value.isNotEmpty;
}
Future<void> submit() async {
if (!isFormValid.value) return;
isSubmitting.value = true;
final result = await loginUseCase(email.value, password.value);
result.fold(
(failure) {
Get.snackbar(
'Error',
_mapFailureToMessage(failure),
backgroundColor: Colors.red,
colorText: Colors.white,
);
},
(user) {
Get.offAllNamed(AppRoutes.home);
},
);
isSubmitting.value = false;
}
@override
void onClose() {
emailController.dispose();
passwordController.dispose();
super.onClose();
}
}
// UI with reactive validation
Obx(() => TextField(
controller: controller.emailController,
decoration: InputDecoration(
labelText: 'Email',
errorText: controller.emailError.value,
),
keyboardType: TextInputType.emailAddress,
))
Obx(() => ElevatedButton(
onPressed: controller.isFormValid.value && !controller.isSubmitting.value
? controller.submit
: null,
child: controller.isSubmitting.value
? CircularProgressIndicator(color: Colors.white)
: Text('Login'),
))
Add semantic labels and screen reader support:
class AccessibleProductCard extends StatelessWidget {
final Product product;
const AccessibleProductCard({
Key? key,
required this.product,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Semantics(
label: '${product.name}, \$${product.price}',
button: true,
onTap: () => Get.toNamed('/product/${product.id}'),
child: GestureDetector(
onTap: () => Get.toNamed('/product/${product.id}'),
child: Container(
constraints: BoxConstraints(minHeight: 48, minWidth: 48), // Touch target
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Semantics(
image: true,
label: 'Product image: ${product.name}',
child: Image.network(product.imageUrl, height: 150),
),
SizedBox(height: 8),
Text(
product.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121), // High contrast
),
),
Text(
'\$${product.price}',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666), // Sufficient contrast
),
),
ExcludeSemantics(
child: Divider(), // Decorative, exclude from screen reader
),
],
),
),
),
);
}
}
// Announce status changes to screen reader
Future<void> addToCart(Product product) async {
cart.add(product);
cartCount.value++;
// Announce to screen reader
SemanticsService.announce(
'${product.name} added to cart',
TextDirection.ltr,
);
Get.snackbar(
'Success',
'${product.name} added to cart',
duration: Duration(seconds: 2),
);
}
Output: Presentation layer files (controllers with Workers, bindings, pages with navigation, widgets with accessibility, form validation, tests).
Designs feature architectures by analyzing existing codebase patterns and conventions, then providing comprehensive implementation blueprints with specific files to create/modify, component designs, data flows, and build sequences