api_result_kit 1.0.1 copy "api_result_kit: ^1.0.1" to clipboard
api_result_kit: ^1.0.1 copied to clipboard

A typed Result/Error handling kit for Flutter apps. Normalizes errors from FastAPI, Express, and generic REST backends.

example/lib/main.dart

import 'package:api_result_kit/api_result_kit.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

void main() => runApp(const ExampleApp());

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'api_result_kit example',
      theme: ThemeData(colorSchemeSeed: const Color(0xFF008080), useMaterial3: true),
      routes: {
        '/login': (_) => const _LoginStubScreen(),
        '/snackbars': (_) => const SnackbarPlayground(),
      },
      home: const UserScreen(),
    );
  }
}

class _LoginStubScreen extends StatelessWidget {
  const _LoginStubScreen();

  @override
  Widget build(BuildContext context) {
    return const Scaffold(body: Center(child: Text('Logged out — back at /login')));
  }
}

/// A minimal model representing the shape of a typical user-profile response.
class UserModel {
  UserModel({required this.id, required this.name});

  factory UserModel.fromJson(Map<String, dynamic> json) =>
      UserModel(id: json['id'] as String, name: json['name'] as String);

  final String id;
  final String name;
}

class UserScreen extends StatefulWidget {
  const UserScreen({super.key});

  @override
  State<UserScreen> createState() => _UserScreenState();
}

class _UserScreenState extends State<UserScreen> {
  ApiResult<UserModel>? _result;
  bool _loading = false;

  /// Demo-only: simulates different backend responses without needing a
  /// real server, so every UI state can be visually checked.
  Future<ApiResult<UserModel>> _simulate(String scenario) async {
    await Future<void>.delayed(const Duration(milliseconds: 600));
    switch (scenario) {
      case 'success':
        return ApiResult.success(UserModel(id: '1', name: 'Aruna'));
      case 'network':
        return ApiResult.failure(ApiErrorParser.parse(
          DioException(
            requestOptions: RequestOptions(path: '/me'),
            type: DioExceptionType.connectionError,
          ),
        ));
      case 'auth':
        return ApiResult.failure(ApiErrorParser.parse(
          DioException(
            requestOptions: RequestOptions(path: '/me'),
            type: DioExceptionType.badResponse,
            response: Response(
              requestOptions: RequestOptions(path: '/me'),
              statusCode: 401,
              data: {'detail': 'Could not validate credentials'},
            ),
          ),
        ));
      case 'validation':
        return ApiResult.failure(ApiErrorParser.parse(
          DioException(
            requestOptions: RequestOptions(path: '/me'),
            type: DioExceptionType.badResponse,
            response: Response(
              requestOptions: RequestOptions(path: '/me'),
              statusCode: 422,
              data: {
                'detail': [
                  {'loc': ['body', 'email'], 'msg': 'invalid email format'},
                ],
              },
            ),
          ),
        ));
      default: // 'server'
        return ApiResult.failure(ApiErrorParser.parse(
          DioException(
            requestOptions: RequestOptions(path: '/me'),
            type: DioExceptionType.badResponse,
            response: Response(
              requestOptions: RequestOptions(path: '/me'),
              statusCode: 500,
              data: {'detail': 'Internal server error'},
            ),
          ),
        ));
    }
  }

  Future<void> _loadUser(String scenario) async {
    setState(() => _loading = true);
    final result = await _simulate(scenario);

    if (!mounted) return;
    setState(() {
      _result = result;
      _loading = false;
    });

    result.maybeWhen(
      failure: (error) =>
          ApiErrorHandler.handle(context, error, onRetry: () => _loadUser(scenario)),
      orElse: () {},
    );
  }

  @override
  void initState() {
    super.initState();
    ApiErrorHandler.onAuthFailure = (context) {
      Navigator.of(context).pushNamedAndRemoveUntil('/login', (_) => false);
    };
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('api_result_kit example'),
        actions: [
          IconButton(
            tooltip: 'RichSnackbar playground',
            icon: const Icon(Icons.notifications_active_outlined),
            onPressed: () => Navigator.of(context).pushNamed('/snackbars'),
          ),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: Wrap(
              spacing: 8,
              children: [
                ElevatedButton(onPressed: () => _loadUser('success'), child: const Text('Success')),
                ElevatedButton(onPressed: () => _loadUser('network'), child: const Text('Network')),
                ElevatedButton(onPressed: () => _loadUser('auth'), child: const Text('Auth')),
                ElevatedButton(onPressed: () => _loadUser('validation'), child: const Text('Validation')),
                ElevatedButton(onPressed: () => _loadUser('server'), child: const Text('Server')),
              ],
            ),
          ),
          Expanded(
            child: _loading
                ? const Center(child: CircularProgressIndicator())
                : _result == null
                    ? const Center(child: Text('Tap a button above'))
                    : ApiResultBuilder<UserModel>(
                        result: _result!,
                        onSuccess: (context, user) => Center(child: Text('Hello, ${user.name}!')),
                        onRetry: () => _loadUser('network'),
                      ),
          ),
        ],
      ),
    );
  }
}
/// Demo screen showing every RichSnackbar variant: layouts (floating/banner,
/// small/large), positions (top/bottom), leading-visual kinds (icon, asset,
/// network image, local SVG, network SVG), animations, a retry action with
/// no auto-dismiss, and manual dismissal via a handle.

/// Comprehensive demo screen — every RichSnackbar combination grouped into
/// sections so you can visually check position × layout × variant × image
/// kind × animation, plus a few real-world patterns (retry, manual
/// dismiss, simultaneous top+bottom, rapid queueing).
class SnackbarPlayground extends StatelessWidget {
  const SnackbarPlayground({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('RichSnackbar playground')),
      body: ListView(
        padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
        children: [
          _Section(
            title: 'Position × Layout (4 layouts × 2 positions = 8)',
            subtitle: 'floatingSmall / floatingLarge / bannerSmall / bannerLarge — top & bottom',
            children: [
              for (final layout in SnackbarLayout.values)
                for (final position in SnackbarPosition.values)
                  _Btn(
                    '${_layoutLabel(layout)} · ${position.name}',
                    () => RichSnackbar.info(
                      context,
                      title: '${_layoutLabel(layout)} · ${position.name}',
                      message: 'This is a ${_layoutLabel(layout).toLowerCase()} snackbar at the ${position.name}.',
                      layout: layout,
                      position: position,
                    ),
                  ),
            ],
          ),
          _Section(
            title: 'Variants (color + default icon)',
            children: [
              _Btn('success', () => RichSnackbar.success(context, title: 'Saved successfully')),
              _Btn('error', () => RichSnackbar.error(context, title: 'Something went wrong')),
              _Btn('warning', () => RichSnackbar.warning(context, title: 'Low on storage')),
              _Btn('info', () => RichSnackbar.info(context, title: 'New update available')),
              _Btn(
                'neutral',
                () => RichSnackbar.show(context, title: 'Just a heads up'),
              ),
            ],
          ),
          _Section(
            title: 'Leading visual kinds',
            subtitle: 'icon / asset image / network image / local SVG / network SVG',
            children: [
              _Btn(
                'icon (default)',
                () => RichSnackbar.success(context, title: 'Default icon visual'),
              ),
              _Btn(
                'asset image',
                () => RichSnackbar.show(
                  context,
                  title: 'Asset image',
                  message: 'Loaded from assets/icons/storage.png',
                  image: const SnackbarImage.asset('assets/icons/storage.png'),
                  layout: SnackbarLayout.floatingLarge,
                ),
              ),
              _Btn(
                'network image',
                () => RichSnackbar.show(
                  context,
                  title: 'New follower',
                  message: 'Aanya Sharma started following you.',
                  image: const SnackbarImage.network('https://i.pravatar.cc/100'),
                  layout: SnackbarLayout.floatingLarge,
                  position: SnackbarPosition.top,
                ),
              ),
              _Btn(
                'local SVG',
                () => RichSnackbar.info(
                  context,
                  title: 'Update available',
                  image: const SnackbarImage.svgAsset('assets/icons/update.svg', color: Colors.white),
                  layout: SnackbarLayout.bannerSmall,
                  position: SnackbarPosition.top,
                ),
              ),
              _Btn(
                'network SVG',
                () => RichSnackbar.error(
                  context,
                  title: 'Sync failed',
                  message: 'Could not reach the server.',
                  image: const SnackbarImage.svgNetwork('https://example.com/icons/cloud-error.svg'),
                  layout: SnackbarLayout.floatingLarge,
                ),
              ),
            ],
          ),
          _Section(
            title: 'Animations',
            subtitle: 'slide / fade / scaleFade / slideFade',
            children: [
              for (final anim in SnackbarAnimation.values)
                _Btn(
                  anim.name,
                  () => RichSnackbar.info(
                    context,
                    title: anim.name,
                    message: 'Entrance/exit animation: ${anim.name}',
                    animation: anim,
                    layout: SnackbarLayout.floatingLarge,
                  ),
                ),
            ],
          ),
          _Section(
            title: 'Real-world patterns',
            children: [
              _Btn(
                'Retry action · no auto-dismiss',
                () => RichSnackbar.error(
                  context,
                  title: 'Sync failed',
                  message: 'Could not reach the server.',
                  layout: SnackbarLayout.floatingLarge,
                  duration: Duration.zero,
                  actionLabel: 'Retry',
                  onAction: () => debugPrint('retry tapped'),
                ),
              ),
              _Btn(
                'Manual dismiss via handle (auto-closes after 2s)',
                () {
                  final handle = RichSnackbar.info(
                    context,
                    title: 'Uploading…',
                    duration: const Duration(seconds: 20),
                    showProgress: false,
                  );
                  Future.delayed(const Duration(seconds: 2), handle.dismiss);
                },
              ),
              _Btn(
                'Top + bottom at the same time',
                () {
                  RichSnackbar.info(context, title: 'Top toast', position: SnackbarPosition.top);
                  RichSnackbar.success(context, title: 'Bottom toast', position: SnackbarPosition.bottom);
                },
              ),
              _Btn(
                'Rapid-fire queue (3 in a row, same position)',
                () {
                  RichSnackbar.info(context, title: 'First');
                  RichSnackbar.warning(context, title: 'Second');
                  RichSnackbar.success(context, title: 'Third');
                },
              ),
              _Btn(
                'Not dismissible, no progress bar, 2s',
                () => RichSnackbar.warning(
                  context,
                  title: 'Cannot be swiped away',
                  duration: const Duration(seconds: 2),
                  dismissible: false,
                  showProgress: false,
                ),
              ),
              _Btn(
                'Custom colors',
                () => RichSnackbar.show(
                  context,
                  title: 'Custom branded toast',
                  backgroundColor: const Color(0xFF6A1B9A),
                  foregroundColor: Colors.white,
                  image: const SnackbarImage.icon(Icons.auto_awesome_rounded),
                ),
              ),
              _Btn(
                'Clear everything',
                () => RichSnackbar.clear(),
              ),
              _Btn(
                'ApiErrorHandler.handle (rich by default)',
                () => ApiErrorHandler.handle(
                  context,
                  const ApiException(
                    type: ApiErrorType.network,
                    message: 'No internet connection',
                    retryable: true,
                  ),
                  onRetry: () => debugPrint('retry'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  String _layoutLabel(SnackbarLayout layout) => switch (layout) {
        SnackbarLayout.floatingSmall => 'Floating small',
        SnackbarLayout.floatingLarge => 'Floating large',
        SnackbarLayout.bannerSmall => 'Banner small',
        SnackbarLayout.bannerLarge => 'Banner large',
      };
}

class _Section extends StatelessWidget {
  const _Section({required this.title, this.subtitle, required this.children});

  final String title;
  final String? subtitle;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 16),
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
            if (subtitle != null) ...[
              const SizedBox(height: 2),
              Text(subtitle!, style: Theme.of(context).textTheme.bodySmall),
            ],
            const SizedBox(height: 10),
            Wrap(spacing: 8, runSpacing: 8, children: children),
          ],
        ),
      ),
    );
  }
}

class _Btn extends StatelessWidget {
  const _Btn(this.label, this.onPressed);

  final String label;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return OutlinedButton(onPressed: onPressed, child: Text(label));
  }
}
2
likes
160
points
46
downloads

Documentation

API reference

Publisher

verified publisherv-nerdy.cloud

Weekly Downloads

A typed Result/Error handling kit for Flutter apps. Normalizes errors from FastAPI, Express, and generic REST backends.

Repository (GitHub)
View/report issues

Topics

#networking #error-handling #dio #state-management #rest-api

License

MIT (license)

Dependencies

dio, flutter, flutter_animate, flutter_svg

More

Packages that depend on api_result_kit