easy_state_getx

GetX adapter for easy_state_core. Provides seamless integration between easy_state's request-driven state management and GetX's reactive system.

Features

  • EasyGetxController - Single value async request controller with StateMixin integration
  • EasyGetxPagedController - Pagination controller with infinite scroll support
  • RxStore - GetX Rx<T> backed state store
  • Auto-sync with obx() - State automatically maps to RxStatus for easy UI binding
  • Stale-while-revalidate - Old data stays visible during refresh
  • Concurrency protection - Duplicate requests automatically deduplicated

Installation

Add to your pubspec.yaml:

dependencies:
  easy_state_getx: ^0.1.0

Then run:

flutter pub get

Quick Start

Single Value Request

import 'package:easy_state_getx/easy_state_getx.dart';

// 1. Define your controller
class UserController extends EasyGetxController<User> {
  final UserApi api;
  UserController(this.api);

  @override
  Future<User> onFetch() => api.getUser();
}

// 2. Register and use in widget
class UserPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final controller = Get.put(UserController(Get.find()));

    return Scaffold(
      body: controller.obx(
        (user) => UserCard(user: user!),
        onLoading: const Center(child: CircularProgressIndicator()),
        onEmpty: const Center(child: Text('No user found')),
        onError: (error) => Center(child: Text('Error: $error')),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: controller.refreshData,
        child: const Icon(Icons.refresh),
      ),
    );
  }
}

Paginated Request

// 1. Define paginated controller
class ArticleListController extends EasyGetxPagedController<Article> {
  final ArticleApi api;
  ArticleListController(this.api);

  @override
  int get pageSize => 20;

  @override
  Future<List<Article>> onFetch({required int page}) {
    return api.getArticles(page: page, pageSize: pageSize);
  }
}

// 2. Use with infinite scroll
class ArticleListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final controller = Get.put(ArticleListController(Get.find()));

    return Scaffold(
      body: controller.obx(
        (articles) => ListView.builder(
          itemCount: articles!.length + 1,
          itemBuilder: (context, index) {
            // Load more trigger
            if (index == articles.length) {
              if (controller.isLoadingMore) {
                return const Center(child: CircularProgressIndicator());
              }
              if (controller.hasMore) {
                return TextButton(
                  onPressed: controller.loadMore,
                  child: const Text('Load More'),
                );
              }
              return const SizedBox.shrink();
            }
            return ArticleTile(articles[index]);
          },
        ),
        onLoading: const Center(child: CircularProgressIndicator()),
        onEmpty: const Center(child: Text('No articles')),
        onError: (error) => Center(child: Text('Error: $error')),
      ),
    );
  }
}

API Reference

EasyGetxController<T>

Base controller for single value async requests.

Property/Method Type Description
autoFetch bool Auto-fetch when controller is ready. Default: true
fetchDelay Duration Delay before auto-fetch. Default: Duration.zero
easyState Rx<EasyValueState<T>> Reactive state object
onFetch() Future<T> Required. Implement your fetch logic
onError(error, stack) void Optional error callback
formatError(error) String Format error for RxStatus. Default: error.toString()
fetch() Future<void> Trigger initial fetch
refreshData() Future<void> Refresh with stale-while-revalidate
retry() Future<void> Retry when in error state

State Access:

controller.easyState.value.status   // EasyStatus (idle/loading/success/empty/error)
controller.easyState.value.data     // T? - your data
controller.easyState.value.error    // Object? - error if any
controller.easyState.value.isRefreshing  // bool - refreshing with old data visible

EasyGetxPagedController<T>

Base controller for paginated async requests.

Property/Method Type Description
autoFetch bool Auto-fetch when ready. Default: true
fetchDelay Duration Delay before auto-fetch. Default: Duration.zero
pageSize int Items per page. Default: 20
easyState Rx<EasyPagedState<T>> Reactive pagination state
onFetch({required int page}) Future<List<T>> Required. Fetch page (1-based)
onError(error, stack) void Refresh/fetch error callback
onLoadMoreError(error, stack) void Load more error callback
refreshData() Future<void> Refresh from page 1
loadMore() Future<void> Load next page
retryLoadMore() Future<void> Retry failed loadMore
resetPagination() void Reset to initial state

Convenience Accessors:

controller.items        // List<T> - all loaded items
controller.page         // int - current page number
controller.hasMore      // bool - more pages available
controller.isRefreshing // bool - refreshing first page
controller.isLoadingMore// bool - loading next page
controller.error        // Object? - refresh/fetch error
controller.loadMoreError// Object? - loadMore error

RxStore<S>

GetX Rx<S> backed implementation of EasyStore<S>.

final rx = EasyValueState<User>.initial().obs;
final store = RxStore<EasyValueState<User>>(rx, onSet: (state) {
  // Called when state changes
});

GetX Extensions

EasyGetXExtension on GetInterface:

// Get or create controller
final controller = Get.getOrPut<UserController>(() => UserController());

// Execute action only if registered
Get.doIfRegistered<UserController, void>((c) => c.refreshData());

// Safe delete (no error if not registered)
await Get.safeDelete<UserController>();

EasyStatusExtension on EasyStatus:

EasyStatus.loading.toRxStatus(hasData: false);  // RxStatus.loading()
EasyStatus.success.toRxStatus(hasData: true);   // RxStatus.success()
EasyStatus.error.toRxStatus(hasData: false, 'Error msg');  // RxStatus.error('Error msg')

Complete Example

User Profile with Pull-to-Refresh

class UserProfileController extends EasyGetxController<UserProfile> {
  final UserRepository repo;
  UserProfileController(this.repo);

  @override
  Future<UserProfile> onFetch() => repo.fetchProfile();

  @override
  void onError(Object error, StackTrace stack) {
    Get.snackbar('Error', formatError(error));
  }

  @override
  String formatError(Object error) {
    if (error is NetworkException) return 'Network error. Please retry.';
    if (error is AuthException) return 'Session expired. Please login.';
    return 'Something went wrong';
  }
}

class UserProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final c = Get.put(UserProfileController(Get.find()));

    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: RefreshIndicator(
        onRefresh: c.refreshData,
        child: c.obx(
          (profile) => ListView(
            children: [
              CircleAvatar(backgroundImage: NetworkImage(profile!.avatarUrl)),
              Text(profile.name, style: Theme.of(context).textTheme.headlineMedium),
              Text(profile.email),
              Text('Joined: ${profile.joinedDate}'),
            ],
          ),
          onLoading: const Center(child: CircularProgressIndicator()),
          onError: (error) => Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(error ?? 'Unknown error'),
                ElevatedButton(onPressed: c.retry, child: const Text('Retry')),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Infinite Scroll List with Error Handling

class ProductListController extends EasyGetxPagedController<Product> {
  final ProductApi api;
  ProductListController(this.api);

  @override
  int get pageSize => 15;

  @override
  Future<List<Product>> onFetch({required int page}) {
    return api.getProducts(page: page, limit: pageSize);
  }

  @override
  void onLoadMoreError(Object error, StackTrace stack) {
    Get.snackbar('Load More Failed', 'Tap to retry');
  }
}

class ProductListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final c = Get.put(ProductListController(Get.find()));

    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: RefreshIndicator(
        onRefresh: c.refreshData,
        child: c.obx(
          (products) => NotificationListener<ScrollNotification>(
            onNotification: (notification) {
              // Auto load more when near bottom
              if (notification is ScrollEndNotification &&
                  notification.metrics.extentAfter < 200 &&
                  c.hasMore &&
                  !c.isLoadingMore) {
                c.loadMore();
              }
              return false;
            },
            child: ListView.builder(
              itemCount: products!.length + 1,
              itemBuilder: (context, index) {
                if (index == products.length) {
                  return _buildFooter(c);
                }
                return ProductCard(products[index]);
              },
            ),
          ),
          onLoading: const Center(child: CircularProgressIndicator()),
          onEmpty: const Center(child: Text('No products available')),
          onError: (error) => Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.error_outline, size: 48),
                Text(error ?? 'Failed to load'),
                ElevatedButton(
                  onPressed: c.refreshData,
                  child: const Text('Retry'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildFooter(ProductListController c) {
    return Obx(() {
      if (c.isLoadingMore) {
        return const Padding(
          padding: EdgeInsets.all(16),
          child: Center(child: CircularProgressIndicator()),
        );
      }
      if (c.loadMoreError != null) {
        return Padding(
          padding: const EdgeInsets.all(16),
          child: Center(
            child: TextButton.icon(
              onPressed: c.retryLoadMore,
              icon: const Icon(Icons.refresh),
              label: const Text('Retry'),
            ),
          ),
        );
      }
      if (!c.hasMore) {
        return const Padding(
          padding: EdgeInsets.all(16),
          child: Center(child: Text('No more items')),
        );
      }
      return const SizedBox.shrink();
    });
  }
}

RxStatus Mapping

The controller automatically maps EasyStatus to GetX's RxStatus:

EasyStatus Has Data RxStatus
idle No empty()
idle Yes success()
loading No loading()
loading Yes success()
success Yes success()
empty - empty()
error No error(message)
error Yes success() (stale data preserved)
Refreshing Yes loadingMore()

License

BSD 3-Clause License. See LICENSE for details.

Libraries

easy_state_getx
GetX adapter for easy_state_core.