remote_client 0.0.1-dev.6
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'),
),
],
),
);
}
}