getx_safe_arch 1.0.1
getx_safe_arch: ^1.0.1 copied to clipboard
A production-grade extension layer for GetX that solves common navigation, controller lifecycle, reactive state, and async safety issues in large-scale Flutter applications. Works as a non-invasive wr [...]
/// getx_safe_arch — Full example app.
///
/// Demonstrates all six plugin features in a realistic product-list /
/// product-detail navigation scenario:
///
/// 1. GetxControllerManager — route-scoped controller registration
/// 2. GetxNavigator — same-route detection + refresh strategy
/// 3. GetxRefreshObserver — data refresh on screen resume
/// 4. `RxSafe<T>` — dispose-safe reactive state
/// 5. SafeControllerMixin — safeAsync + safeUpdate + trackWorker
/// 6. GetxInspector — debug overlay (visible in debug mode only)
library;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:getx_safe_arch/getx_safe_arch.dart';
// ============================================================================
// Routes
// ============================================================================
abstract final class Routes {
static const home = '/';
static const productList = '/products';
static const productDetail = '/products/detail';
}
// ============================================================================
// Domain — fake data layer
// ============================================================================
class Product {
const Product({required this.id, required this.name, required this.price});
final String id;
final String name;
final double price;
}
/// Simulates a remote repository with artificial latency.
class ProductRepository {
static final _store = {
'1': const Product(id: '1', name: 'Wireless Headphones', price: 89.99),
'2': const Product(id: '2', name: 'Mechanical Keyboard', price: 149.99),
'3': const Product(id: '3', name: 'USB-C Hub', price: 39.99),
};
Future<List<Product>> getAll() async {
await Future<void>.delayed(const Duration(milliseconds: 600));
return _store.values.toList();
}
Future<Product> getById(String id) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
final p = _store[id];
if (p == null) throw StateError('Product $id not found');
return p;
}
}
// ============================================================================
// Feature: Product List
// ============================================================================
class ProductListController extends GetxController with SafeControllerMixin {
ProductListController(this._repo);
final ProductRepository _repo;
// RxSafe prevents post-dispose mutations from async gaps.
final _products = RxSafe<List<Product>>([]);
final _isLoading = RxSafe<bool>(false);
final _error = RxSafe<String?>('');
List<Product> get products => _products.value;
bool get isLoading => _isLoading.value;
String? get error => _error.value;
@override
void onInit() {
super.onInit();
// trackWorker auto-disposes this ever() on onClose() —
// no manual cleanup needed.
trackWorker(
ever(_isLoading.rx, (loading) {
debugPrint('[ProductListController] isLoading changed → $loading');
}),
);
}
Future<void> fetchProducts() async {
await safeAsync(() async {
safeUpdate(() {
_isLoading.value = true;
_error.value = null;
});
try {
final data = await _repo.getAll();
// safeUpdate no-ops if onClose() was called during the await.
safeUpdate(() {
_products.value = data;
_isLoading.value = false;
});
} catch (e) {
safeUpdate(() {
_error.value = e.toString();
_isLoading.value = false;
});
}
});
}
@override
void onClose() {
_products.dispose();
_isLoading.dispose();
_error.dispose();
super.onClose();
}
}
class ProductListScreen extends StatelessWidget {
const ProductListScreen({super.key});
@override
Widget build(BuildContext context) {
// Route-scoped: auto-disposed when /products is popped.
final controller = GetxControllerManager.put(
ProductListController(ProductRepository()),
scope: ControllerScope.route,
);
return GetxRefreshObserver(
routeName: Routes.productList,
// Called once on first push — equivalent to onInit fetch.
onFirstLoad: controller.fetchProducts,
// Called every time we return from ProductDetailScreen.
onResume: controller.fetchProducts,
child: Scaffold(
appBar: AppBar(
title: const Text('Products'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: controller.fetchProducts,
),
],
),
body: Obx(() {
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: ${controller.error}'),
TextButton(
onPressed: controller.fetchProducts,
child: const Text('Retry'),
),
],
),
);
}
return ListView.builder(
itemCount: controller.products.length,
itemBuilder: (_, index) {
final product = controller.products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// GetxNavigator detects same-route navigation and applies
// RefreshStrategy.smart — no duplicate route push.
GetxNavigator.toNamed(
Routes.productDetail,
arguments: {'id': product.id},
refreshStrategy: RefreshStrategy.smart,
);
},
);
},
);
}),
),
);
}
}
// ============================================================================
// Feature: Product Detail
// ============================================================================
class ProductDetailController extends GetxController with SafeControllerMixin {
ProductDetailController(this._repo);
final ProductRepository _repo;
final _product = RxSafe<Product?>(null);
final _isLoading = RxSafe<bool>(false);
final _error = RxSafe<String?>('');
Product? get product => _product.value;
bool get isLoading => _isLoading.value;
String? get error => _error.value;
Future<void> load(String productId) async {
await safeAsync(() async {
safeUpdate(() {
_isLoading.value = true;
_error.value = null;
});
try {
final data = await _repo.getById(productId);
safeUpdate(() {
_product.value = data;
_isLoading.value = false;
});
} catch (e) {
safeUpdate(() {
_error.value = e.toString();
_isLoading.value = false;
});
}
});
}
@override
void onClose() {
_product.dispose();
_isLoading.dispose();
_error.dispose();
super.onClose();
}
}
class ProductDetailScreen extends StatelessWidget {
const ProductDetailScreen({super.key});
@override
Widget build(BuildContext context) {
final args = Get.arguments as Map<String, dynamic>;
final productId = args['id'] as String;
// Route-scoped: auto-disposed when /products/detail is popped.
final controller = GetxControllerManager.put(
ProductDetailController(ProductRepository()),
scope: ControllerScope.route,
);
return GetxRefreshObserver(
routeName: Routes.productDetail,
// onFirstLoad fires when the route is first pushed.
onFirstLoad: () => controller.load(productId),
// onRefreshDispatched fires when GetxNavigator dispatches a refresh
// to this route (e.g., same-route navigation with new arguments).
onRefreshDispatched: () => controller.load(productId),
child: Scaffold(
appBar: AppBar(title: const Text('Product Detail')),
body: Obx(() {
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null) {
return Center(child: Text('Error: ${controller.error}'));
}
final product = controller.product;
if (product == null) {
return const Center(child: Text('No data'));
}
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 12),
Text(
'\$${product.price.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.green,
),
),
const SizedBox(height: 24),
Text('Product ID: ${product.id}'),
],
),
);
}),
),
);
}
}
// ============================================================================
// Home screen — plugin feature showcase
// ============================================================================
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('getx_safe_arch Example')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_FeatureCard(
title: '1 · Scoped Controller Manager',
subtitle:
'Navigate to the product list. Controllers are registered as '
'route-scoped and auto-disposed on back navigation.',
onTap: () => GetxNavigator.toNamed(Routes.productList),
),
_FeatureCard(
title: '2 · Safe Navigation',
subtitle:
'Tap a product, then tap the same product again. GetxNavigator '
'detects same-route navigation and dispatches a refresh instead '
'of pushing a duplicate route.',
onTap: () => GetxNavigator.toNamed(Routes.productList),
),
_FeatureCard(
title: '3 · Screen Refresh on Resume',
subtitle:
'Navigate to the product list, open a detail, go back. '
'GetxRefreshObserver calls fetchProducts() on return.',
onTap: () => GetxNavigator.toNamed(Routes.productList),
),
_FeatureCard(
title: '4 · RxSafe<T>',
subtitle:
'All controllers in this app use RxSafe instead of .obs. '
'Post-dispose writes are silently dropped.',
onTap: null,
),
_FeatureCard(
title: '5 · SafeControllerMixin',
subtitle:
'All async fetch methods use safeAsync + safeUpdate. Workers '
'are tracked with trackWorker and auto-disposed.',
onTap: null,
),
_FeatureCard(
title: '6 · GetxInspector',
subtitle:
'See the red bug icon in the bottom-right corner. Tap to view '
'active controllers, their scopes, and the route stack.',
onTap: null,
),
],
),
);
}
}
class _FeatureCard extends StatelessWidget {
const _FeatureCard({
required this.title,
required this.subtitle,
required this.onTap,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(subtitle),
),
trailing: onTap != null ? const Icon(Icons.arrow_forward_ios) : null,
onTap: onTap,
),
);
}
}
// ============================================================================
// App entry point
// ============================================================================
void main() {
GetxInspector.enable(); // debug only — no-ops in release
runApp(const GetxSafeArchExampleApp());
}
class GetxSafeArchExampleApp extends StatelessWidget {
const GetxSafeArchExampleApp({super.key});
@override
Widget build(BuildContext context) {
return GetxInspector.wrap(
child: GetMaterialApp(
title: 'getx_safe_arch Example',
debugShowCheckedModeBanner: false,
navigatorObservers: [
safeArchObserver, // drives route-scoped auto-disposal
safeArchRouteObserver, // drives GetxRefreshObserver.onResume
],
initialRoute: Routes.home,
getPages: [
GetPage(name: Routes.home, page: () => const HomeScreen()),
GetPage(
name: Routes.productList,
page: () => const ProductListScreen(),
),
GetPage(
name: Routes.productDetail,
page: () => const ProductDetailScreen(),
),
],
),
);
}
}