api_result_kit 1.0.1
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.
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));
}
}