remote_client 0.0.1-dev.6 copy "remote_client: ^0.0.1-dev.6" to clipboard
remote_client: ^0.0.1-dev.6 copied to clipboard

A high-performance, enterprise-grade HTTP client package for Flutter/Dart with retry mechanisms, authentication, error handling, and connectivity checking.

example/lib/main.dart

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

import 'package:flutter/material.dart';
import 'package:remote_client/remote_client.dart';

void main() {
  runApp(const RemoteClientExampleApp());
}

/// Root widget that hosts the interactive demo.
class RemoteClientExampleApp extends StatelessWidget {
  const RemoteClientExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'remote_client Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueAccent),
        useMaterial3: true,
      ),
      home: const RemoteClientDemoPage(),
    );
  }
}

/// Stateful page that executes requests against the test server.
class RemoteClientDemoPage extends StatefulWidget {
  const RemoteClientDemoPage({super.key});

  @override
  State<RemoteClientDemoPage> createState() => _RemoteClientDemoPageState();
}

class _RemoteClientDemoPageState extends State<RemoteClientDemoPage> {
  static const String _defaultBaseUrl = 'http://192.168.3.192:4000/api';

  final InMemoryTokenProvider _tokenProvider = InMemoryTokenProvider();
  late ExampleUnauthorizedHandler _unauthorizedHandler;
  late TextEditingController _baseUrlController;

  ExampleRemoteClient? _client;
  bool _isLoading = false;
  String _output = 'Select an action to call the test server.';

  @override
  void initState() {
    super.initState();
    _baseUrlController = TextEditingController(text: _defaultBaseUrl);
    _initializeClient();
  }

  @override
  void dispose() {
    _baseUrlController.dispose();
    super.dispose();
  }

  void _initializeClient() {
    _unauthorizedHandler = ExampleUnauthorizedHandler(
      onUnauthorized: () {
        setState(() {
          _output =
              'Received 401/403 from server. Token cleared – please login again.';
        });
      },
      tokenProvider: _tokenProvider,
    );

    _client = ExampleRemoteClient(
      baseUrl: _baseUrlController.text.trim(),
      tokenProvider: _tokenProvider,
      unauthorizedHandler: _unauthorizedHandler,
    );
  }

  List<DemoAction> get _actions {
    final client = _client;
    if (client == null) {
      return const [];
    }

    return [
      DemoAction(
        title: 'GET /health',
        description: 'Basic health check endpoint.',
        run: client.getHealth,
      ),
      DemoAction(
        title: 'GET /users',
        description: 'Fetch all seeded users.',
        run: client.getUsers,
      ),
      DemoAction(
        title: 'GET /users?delay=2000',
        description: 'Simulate a slow response to test loading states.',
        run: () => client.getUsers(delayMs: 2000),
      ),
      DemoAction(
        title: 'GET /users/404',
        description: 'Demonstrates 404 handling.',
        run: client.getMissingUser,
      ),
      DemoAction(
        title: 'POST /users',
        description: 'Create a user with random data.',
        run: client.createUser,
      ),
      DemoAction(
        title: 'GET /errors/bad-request',
        description: 'Trigger validation error (400).',
        run: client.triggerBadRequest,
      ),
      DemoAction(
        title: 'GET /errors/server',
        description: 'Trigger a 500 server error.',
        run: client.triggerServerError,
      ),
      DemoAction(
        title: 'GET /errors/bad-response',
        description: 'Receive invalid JSON to exercise parser failure.',
        run: client.triggerBadJson,
      ),
      DemoAction(
        title: 'GET /errors/timeout?delay=9000',
        description: 'Expect a timeout failure.',
        run: client.triggerTimeout,
      ),
      DemoAction(
        title: 'GET /retry/flaky',
        description: 'Flaky endpoint that succeeds on the 3rd attempt.',
        run: client.callFlakyEndpoint,
      ),
      DemoAction(
        title: 'GET /retry/rate-limit',
        description: 'Returns 429 until the 4th call.',
        run: client.callRateLimitedEndpoint,
      ),
      DemoAction(
        title: 'POST /auth/login',
        description: 'Obtain bearer token and store it in the token provider.',
        run: client.login,
      ),
      DemoAction(
        title: 'GET /auth/protected',
        description: 'Access protected resource (requires token + API key).',
        run: client.getProtectedResource,
      ),
      DemoAction(
        title: 'POST /auth/logout',
        description: 'Invalidate the current token on the server.',
        run: client.logout,
      ),
      DemoAction(
        title: 'GET /headers/inspect',
        description: 'Inspect headers sent by remote_client.',
        run: client.inspectHeaders,
      ),
      DemoAction(
        title: 'GET /meta/paginated?page=2&limit=5',
        description: 'View paginated payload and meta data.',
        run: client.getPaginatedData,
      ),
    ];
  }

  Future<void> _execute(DemoAction action) async {
    setState(() {
      _isLoading = true;
      _output = 'Running ${action.title}...';
    });

    try {
      final result = await action.run();
      if (!mounted) return;
      setState(() {
        _output = result;
      });
    } catch (error) {
      if (!mounted) return;
      setState(() {
        _output = '❌ Unexpected error: $error';
      });
    } finally {
      if (!mounted) return;
      setState(() {
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final tokenStatus = _tokenProvider.hasValidToken ? 'available' : 'missing';

    return Scaffold(
      appBar: AppBar(
        title: const Text('remote_client Playground'),
        actions: [
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12),
            child: Chip(
              backgroundColor: _tokenProvider.hasValidToken
                  ? Colors.green.shade100
                  : null,
              label: Text('Token: $tokenStatus'),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          _BaseUrlConfigurator(
            controller: _baseUrlController,
            onApply: () {
              setState(() {
                _initializeClient();
                _output =
                    'Updated base URL to ${_baseUrlController.text.trim()}.';
              });
            },
          ),
          if (_isLoading) const LinearProgressIndicator(minHeight: 2),
          Expanded(
            child: ListView.separated(
              padding: const EdgeInsets.all(16),
              itemBuilder: (context, index) {
                final action = _actions[index];
                return Card(
                  elevation: 1,
                  child: ListTile(
                    title: Text(action.title),
                    subtitle: Text(action.description),
                    trailing: const Icon(Icons.chevron_right),
                    onTap: () => _execute(action),
                  ),
                );
              },
              separatorBuilder: (_, __) => const SizedBox(height: 8),
              itemCount: _actions.length,
            ),
          ),
          _ResultPane(output: _output),
        ],
      ),
    );
  }
}

/// Simple token provider that stores values in-memory.
class InMemoryTokenProvider implements TokenProvider {
  String? _token;
  DateTime? _expiresAt;

  void saveToken(String token, {required Duration ttl}) {
    _token = token;
    _expiresAt = DateTime.now().add(ttl);
  }

  void clear() {
    _token = null;
    _expiresAt = null;
  }

  @override
  String? getAccessToken() => hasValidToken ? _token : null;

  @override
  bool get hasValidToken {
    if (_token == null) return false;
    if (_expiresAt == null) return true;
    return DateTime.now().isBefore(_expiresAt!);
  }
}

/// Unauthorized handler that clears the stored token.
class ExampleUnauthorizedHandler implements UnauthorizedHandler {
  ExampleUnauthorizedHandler({
    required this.onUnauthorized,
    required this.tokenProvider,
  });

  final VoidCallback onUnauthorized;
  final InMemoryTokenProvider tokenProvider;

  @override
  Future<void> handleUnauthorized() async {
    tokenProvider.clear();
    onUnauthorized();
  }
}

/// Wrapper that exposes high-level demo actions.
class ExampleRemoteClient {
  ExampleRemoteClient({
    required String baseUrl,
    required InMemoryTokenProvider tokenProvider,
    required UnauthorizedHandler unauthorizedHandler,
  }) : _tokenProvider = tokenProvider,
       _client = RemoteClientFactory.builder()
           .baseUrl(baseUrl)
           .withAuth(
             tokenProvider: tokenProvider,
             unauthorizedHandler: unauthorizedHandler,
             locale: 'en-US',
           )
           .withRetry(RetryPolicy.aggressive)
           .withTransformationHooks(
             TransformationHooks(
               onRequestTransform: (endpoint, data, options) {
                 // Inject API key for protected routes.
                 if (endpoint.startsWith('/auth/protected')) {
                   options.headers['x-api-key'] = 'super-secret';
                 }
                 return data;
               },
             ),
           )
           .enableLogging()
           .build();

  final RemoteClient _client;
  final InMemoryTokenProvider _tokenProvider;

  Future<String> getHealth() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/health',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> getUsers({int? delayMs}) async {
    final response = await _client.get<List<Map<String, dynamic>>>(
      '/users',
      queryParams: delayMs != null ? {'delay': delayMs} : null,
      fromJson: _listFromJson,
    );
    return _formatResult(response);
  }

  Future<String> createUser() async {
    final timestamp = DateTime.now().millisecondsSinceEpoch;
    final response = await _client.post<Map<String, dynamic>>(
      '/users',
      data: {'name': 'User $timestamp', 'email': 'user$timestamp@example.com'},
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> getMissingUser() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/users/404',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> triggerBadRequest() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/errors/bad-request',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> triggerServerError() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/errors/server',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> triggerBadJson() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/errors/bad-response',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> triggerTimeout() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/errors/timeout',
      queryParams: const {'delay': 9000},
      timeout: RequestTimeoutConfig.quick,
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> callFlakyEndpoint() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/retry/flaky',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> callRateLimitedEndpoint() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/retry/rate-limit',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> login() async {
    final response = await _client.post<Map<String, dynamic>>(
      '/auth/login',
      data: {'username': 'demo', 'password': 'demo-password'},
      fromJson: _mapFromJson,
    );

    return response.fold((failure) => _formatFailure(failure), (success) {
      final token = success.data?['token'] as String?;
      final expiresIn = success.data?['expiresIn'] as int? ?? 3600;
      if (token != null) {
        _tokenProvider.saveToken(token, ttl: Duration(seconds: expiresIn));
      }
      return _formatSuccess(success);
    });
  }

  Future<String> logout() async {
    if (!_tokenProvider.hasValidToken) {
      return '🔑 No token available – login first.';
    }

    final response = await _client.post<void>('/auth/logout');
    return response.fold((failure) => _formatFailure(failure), (success) {
      _tokenProvider.clear();
      return _formatSuccess(success, fallbackMessage: 'Logged out.');
    });
  }

  Future<String> getProtectedResource() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/auth/protected',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> inspectHeaders() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/headers/inspect',
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  Future<String> getPaginatedData() async {
    final response = await _client.get<Map<String, dynamic>>(
      '/meta/paginated',
      queryParams: const {'page': 2, 'limit': 5},
      fromJson: _mapFromJson,
    );
    return _formatResult(response);
  }

  String _formatResult<T>(Either<Failure, BaseResponse<T>> result) {
    return result.fold(
      (failure) => _formatFailure(failure),
      (success) => _formatSuccess(success),
    );
  }

  String _formatFailure(Failure failure) {
    final statusCode = failure.response?.statusCode;
    return '❌ ${failure.runtimeType} — ${failure.errorMessage}'
        '${statusCode != null ? ' (status: $statusCode)' : ''}';
  }

  String _formatSuccess<T>(
    BaseResponse<T> response, {
    String? fallbackMessage,
  }) {
    final encoder = const JsonEncoder.withIndent('  ');
    final payload = <String, dynamic>{
      'statusCode': response.statusCode,
      'success': response.success,
      if (response.message != null) 'message': response.message,
      'data': response.data,
      if (response.meta != null) 'meta': response.meta,
    };

    final body = encoder.convert(payload);
    return '✅ Success\n$body'
        '${fallbackMessage != null ? '\n$fallbackMessage' : ''}';
  }
}

/// Convert dynamic JSON into a map.
Map<String, dynamic> _mapFromJson(Object? json) {
  if (json == null) return <String, dynamic>{};
  if (json is Map<String, dynamic>) return json;
  if (json is Map) {
    return json.map((key, value) => MapEntry(key.toString(), value));
  }
  throw const FormatException('Expected JSON object.');
}

/// Convert dynamic JSON into a list of maps.
List<Map<String, dynamic>> _listFromJson(Object? json) {
  if (json == null) return const [];
  if (json is List) {
    return json.map((item) => _mapFromJson(item)).toList();
  }
  throw const FormatException('Expected JSON array.');
}

/// Lightweight data class for UI actions.
class DemoAction {
  const DemoAction({
    required this.title,
    required this.description,
    required this.run,
  });

  final String title;
  final String description;
  final Future<String> Function() run;
}

class _BaseUrlConfigurator extends StatelessWidget {
  const _BaseUrlConfigurator({required this.controller, required this.onApply});

  final TextEditingController controller;
  final VoidCallback onApply;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: controller,
              decoration: const InputDecoration(
                labelText: 'Test server base URL',
                hintText: 'http://localhost:4000/api',
              ),
            ),
          ),
          const SizedBox(width: 12),
          FilledButton(onPressed: onApply, child: const Text('Apply')),
        ],
      ),
    );
  }
}

class _ResultPane extends StatelessWidget {
  const _ResultPane({required this.output});

  final String output;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      color: Theme.of(context).colorScheme.surfaceVariant,
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Text('Response', style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 8),
          SelectableText(
            output,
            style: Theme.of(
              context,
            ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
          ),
        ],
      ),
    );
  }
}
2
likes
0
points
219
downloads

Publisher

unverified uploader

Weekly Downloads

A high-performance, enterprise-grade HTTP client package for Flutter/Dart with retry mechanisms, authentication, error handling, and connectivity checking.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

dio, flutter

More

Packages that depend on remote_client