getx_safe_arch 1.0.1 copy "getx_safe_arch: ^1.0.1" to clipboard
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 [...]

example/lib/main.dart

/// 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(),
          ),
        ],
      ),
    );
  }
}
0
likes
150
points
22
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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 wrapper — never modifies GetX internals.

Repository (GitHub)
View/report issues

Topics

#getx #state-management #navigation #architecture #lifecycle

License

MIT (license)

Dependencies

flutter, get

More

Packages that depend on getx_safe_arch