Skip to main content

smart_api_errors_parser 0.1.1 copy "smart_api_errors_parser: ^0.1.1" to clipboard
smart_api_errors_parser: ^0.1.1 copied to clipboard

Maps inconsistent API error payloads into typed exceptions, form errors, and localized messages.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';

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

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

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

  @override
  Widget build(BuildContext context) => MaterialApp(
        debugShowCheckedModeBanner: false,
        title: 'API Error Mapper Example',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(
            seedColor: const Color(0xff6750a4),
          ),
          useMaterial3: true,
        ),
        home: const RegistrationPage(),
      );
}

enum BackendErrorShape {
  nestedMap('Nested map'),
  detailsList('Details list');

  const BackendErrorShape(this.label);
  final String label;
}

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

  @override
  State<RegistrationPage> createState() => _RegistrationPageState();
}

class _RegistrationPageState extends State<RegistrationPage> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController(text: 'used@example.com');
  final _phoneController = TextEditingController(text: '+123456789');
  final _emailFocus = FocusNode();
  final _phoneFocus = FocusNode();

  late final RegistrationRepository _repository;
  BackendErrorShape _shape = BackendErrorShape.detailsList;
  Map<String, String> _fieldErrors = const {};
  String? _globalError;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    final dio = Dio(BaseOptions(baseUrl: 'https://api.example.test'))
      ..httpClientAdapter = ExampleBackendAdapter()
      ..interceptors.add(
        ApiErrorInterceptor(
          mapper: ApiErrorMapper(localizer: localizeError),
        ),
      );
    _repository = RegistrationRepository(dio);
  }

  @override
  void dispose() {
    _emailController.dispose();
    _phoneController.dispose();
    _emailFocus.dispose();
    _phoneFocus.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    setState(() {
      _fieldErrors = const {};
      _globalError = null;
    });
    if (!(_formKey.currentState?.validate() ?? false)) return;

    setState(() => _loading = true);
    try {
      await _repository.register(
        email: _emailController.text.trim(),
        phone: _phoneController.text.trim(),
        shape: _shape,
      );
      if (!mounted) return;
      _showMessage('Account created successfully.');
    } on ApiValidationException catch (error) {
      if (!mounted) return;
      setState(() {
        _fieldErrors = error.fields;
        _globalError =
            error.globalErrors.isEmpty ? null : error.globalErrors.first;
      });
      _formKey.currentState?.validate();
      WidgetsBinding.instance.addPostFrameCallback((_) => _focusFirstError());
    } on ApiAuthenticationException {
      if (!mounted) return;
      _showMessage('Session expired. Please sign in again.');
    } on ApiException catch (error) {
      if (!mounted) return;
      _showMessage(error.message);
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  void _focusFirstError() {
    if (_fieldErrors.containsKey('email')) {
      _emailFocus.requestFocus();
    } else if (_fieldErrors.containsKey('phone')) {
      _phoneFocus.requestFocus();
    }
  }

  void _clearFieldError(String field) {
    if (!_fieldErrors.containsKey(field)) return;
    setState(() {
      _fieldErrors = Map<String, String>.of(_fieldErrors)..remove(field);
    });
  }

  void _showMessage(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    return Scaffold(
      appBar: AppBar(title: const Text('API error mapper')),
      body: SafeArea(
        child: Center(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(24),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 560),
              child: Card(
                elevation: 0,
                color: colors.surfaceContainerLow,
                child: Padding(
                  padding: const EdgeInsets.all(28),
                  child: Form(
                    key: _formKey,
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: [
                        Icon(
                          Icons.person_add_alt_1_rounded,
                          size: 44,
                          color: colors.primary,
                        ),
                        const SizedBox(height: 16),
                        Text(
                          'Create an account',
                          textAlign: TextAlign.center,
                          style: Theme.of(context).textTheme.headlineSmall,
                        ),
                        const SizedBox(height: 8),
                        Text(
                          'Submit defaults to see mapped server errors. '
                          'Change email to see success.',
                          textAlign: TextAlign.center,
                          style: Theme.of(context).textTheme.bodyMedium,
                        ),
                        const SizedBox(height: 28),
                        Text(
                          'Backend response shape',
                          style: Theme.of(context).textTheme.labelLarge,
                        ),
                        const SizedBox(height: 8),
                        SegmentedButton<BackendErrorShape>(
                          segments: [
                            for (final shape in BackendErrorShape.values)
                              ButtonSegment(
                                value: shape,
                                label: Text(shape.label),
                              ),
                          ],
                          selected: {_shape},
                          onSelectionChanged: _loading
                              ? null
                              : (selection) {
                                  setState(() => _shape = selection.first);
                                },
                        ),
                        const SizedBox(height: 24),
                        if (_globalError case final message?) ...[
                          Semantics(
                            liveRegion: true,
                            child: Container(
                              padding: const EdgeInsets.all(14),
                              decoration: BoxDecoration(
                                color: colors.errorContainer,
                                borderRadius: BorderRadius.circular(12),
                              ),
                              child: Row(
                                children: [
                                  Icon(
                                    Icons.error_outline,
                                    color: colors.onErrorContainer,
                                  ),
                                  const SizedBox(width: 12),
                                  Expanded(
                                    child: Text(
                                      message,
                                      style: TextStyle(
                                        color: colors.onErrorContainer,
                                      ),
                                    ),
                                  ),
                                ],
                              ),
                            ),
                          ),
                          const SizedBox(height: 16),
                        ],
                        TextFormField(
                          controller: _emailController,
                          focusNode: _emailFocus,
                          enabled: !_loading,
                          keyboardType: TextInputType.emailAddress,
                          textInputAction: TextInputAction.next,
                          autofillHints: const [AutofillHints.email],
                          decoration: const InputDecoration(
                            labelText: 'Email',
                            prefixIcon: Icon(Icons.email_outlined),
                            border: OutlineInputBorder(),
                          ),
                          onChanged: (_) => _clearFieldError('email'),
                          validator: (value) {
                            if (value == null || value.trim().isEmpty) {
                              return 'Email is required';
                            }
                            if (!value.contains('@')) {
                              return 'Enter a valid email';
                            }
                            return _fieldErrors['email'];
                          },
                        ),
                        const SizedBox(height: 16),
                        TextFormField(
                          controller: _phoneController,
                          focusNode: _phoneFocus,
                          enabled: !_loading,
                          keyboardType: TextInputType.phone,
                          textInputAction: TextInputAction.done,
                          autofillHints: const [AutofillHints.telephoneNumber],
                          decoration: const InputDecoration(
                            labelText: 'Phone',
                            prefixIcon: Icon(Icons.phone_outlined),
                            border: OutlineInputBorder(),
                          ),
                          onChanged: (_) => _clearFieldError('phone'),
                          onFieldSubmitted: (_) => _submit(),
                          validator: (value) {
                            if (value == null || value.trim().isEmpty) {
                              return 'Phone is required';
                            }
                            return _fieldErrors['phone'];
                          },
                        ),
                        const SizedBox(height: 24),
                        FilledButton.icon(
                          onPressed: _loading ? null : _submit,
                          icon: _loading
                              ? const SizedBox.square(
                                  dimension: 18,
                                  child: CircularProgressIndicator(
                                    strokeWidth: 2,
                                  ),
                                )
                              : const Icon(Icons.app_registration),
                          label: Text(
                            _loading ? 'Creating account…' : 'Register',
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

String localizeError(ApiErrorMessage error) => switch (error.code) {
      'email_taken' => 'This email is already in use',
      'invalid_phone' => 'Enter a valid phone number',
      _ => error.message,
    };

class RegistrationRepository {
  RegistrationRepository(this._dio);
  final Dio _dio;

  Future<void> register({
    required String email,
    required String phone,
    required BackendErrorShape shape,
  }) async {
    await _dio.post<void>(
      '/register',
      data: {'email': email, 'phone': phone, 'shape': shape.name},
    ).unwrapApiErrors();
  }
}

/// Offline fake backend. Replace with Dio's default adapter for a real API.
class ExampleBackendAdapter implements HttpClientAdapter {
  @override
  Future<ResponseBody> fetch(
    RequestOptions options,
    Stream<List<int>>? requestStream,
    Future<void>? cancelFuture,
  ) async {
    await Future<void>.delayed(const Duration(milliseconds: 350));
    final data = Map<String, Object?>.from(options.data as Map);

    if (data['email'] != 'used@example.com') {
      return _jsonResponse({'id': 'user_123'}, 201);
    }

    final nested = data['shape'] == BackendErrorShape.nestedMap.name;
    return nested
        ? _jsonResponse({
            'message': 'Validation failed',
            'errors': {
              'email': [
                {'message': 'Already used', 'code': 'email_taken'},
              ],
              'phone': [
                {'message': 'Invalid phone number', 'code': 'invalid_phone'},
              ],
            },
          }, 422)
        : _jsonResponse({
            'message': 'Validation failed',
            'details': [
              {
                'field': 'email',
                'message': 'Already used',
                'code': 'email_taken',
              },
              {
                'field': 'phone',
                'message': 'Invalid phone number',
                'code': 'invalid_phone',
              },
            ],
          }, 422);
  }

  ResponseBody _jsonResponse(Object body, int statusCode) {
    return ResponseBody.fromString(
      jsonEncode(body),
      statusCode,
      headers: {
        Headers.contentTypeHeader: [Headers.jsonContentType],
      },
    );
  }

  @override
  void close({bool force = false}) {}
}
0
likes
160
points
48
downloads

Documentation

API reference

Publisher

verified publisherpinz.dev

Weekly Downloads

Maps inconsistent API error payloads into typed exceptions, form errors, and localized messages.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

dio

More

Packages that depend on smart_api_errors_parser