Initial commit: Plan B Logistics Flutter app with dark mode and responsive design
Implements complete refactor of Ionic Angular logistics app to Flutter/Dart with: - Svrnty dark mode console theme (Material Design 3) - Responsive layouts (mobile, tablet, desktop) following FRONTEND standards - CQRS API integration with Result<T> error handling - OAuth2/OIDC authentication support (mocked for initial testing) - Delivery route and delivery management features - Multi-language support (EN/FR) with i18n - Native integrations (camera, phone calls, maps) - Strict typing throughout codebase - Mock data for UI testing without backend Follows all FRONTEND style guides, design patterns, and conventions. App is running in dark mode and fully responsive across all device sizes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'types.dart';
|
||||
import 'openapi_config.dart';
|
||||
|
||||
class CqrsApiClient {
|
||||
final ApiClientConfig config;
|
||||
late final http.Client _httpClient;
|
||||
|
||||
CqrsApiClient({
|
||||
required this.config,
|
||||
http.Client? httpClient,
|
||||
}) {
|
||||
_httpClient = httpClient ?? http.Client();
|
||||
}
|
||||
|
||||
String get baseUrl => config.baseUrl;
|
||||
|
||||
Future<Result<T>> executeQuery<T>({
|
||||
required String endpoint,
|
||||
required Serializable query,
|
||||
required T Function(Map<String, dynamic>) fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/query/$endpoint');
|
||||
final headers = _buildHeaders();
|
||||
|
||||
final response = await _httpClient
|
||||
.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(query.toJson()),
|
||||
)
|
||||
.timeout(config.timeout);
|
||||
|
||||
return _handleResponse<T>(response, fromJson);
|
||||
} on TimeoutException {
|
||||
return Result.error(ApiError.timeout());
|
||||
} catch (e, stackTrace) {
|
||||
return Result.error(
|
||||
ApiError.unknown(
|
||||
'Failed to execute query: ${e.toString()}',
|
||||
exception: Exception(stackTrace.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<PaginatedResult<T>>> executePaginatedQuery<T>({
|
||||
required String endpoint,
|
||||
required Serializable query,
|
||||
required T Function(Map<String, dynamic>) itemFromJson,
|
||||
required int page,
|
||||
required int pageSize,
|
||||
List<FilterCriteria>? filters,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'$baseUrl/api/query/$endpoint?page=$page&pageSize=$pageSize',
|
||||
);
|
||||
final headers = _buildHeaders();
|
||||
|
||||
final queryData = {
|
||||
...query.toJson(),
|
||||
if (filters != null && filters.isNotEmpty)
|
||||
'filters': filters.map((f) => f.toJson()).toList(),
|
||||
};
|
||||
|
||||
final response = await _httpClient
|
||||
.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(queryData),
|
||||
)
|
||||
.timeout(config.timeout);
|
||||
|
||||
return _handlePaginatedResponse<T>(response, itemFromJson, page, pageSize);
|
||||
} on TimeoutException {
|
||||
return Result.error(ApiError.timeout());
|
||||
} catch (e, stackTrace) {
|
||||
return Result.error(
|
||||
ApiError.unknown(
|
||||
'Failed to execute paginated query: ${e.toString()}',
|
||||
exception: Exception(stackTrace.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<void>> executeCommand({
|
||||
required String endpoint,
|
||||
required Serializable command,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/command/$endpoint');
|
||||
final headers = _buildHeaders();
|
||||
|
||||
final response = await _httpClient
|
||||
.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(command.toJson()),
|
||||
)
|
||||
.timeout(config.timeout);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return Result.success(null);
|
||||
} else {
|
||||
return _handleErrorResponse(response);
|
||||
}
|
||||
} on TimeoutException {
|
||||
return Result.error(ApiError.timeout());
|
||||
} catch (e, stackTrace) {
|
||||
return Result.error(
|
||||
ApiError.unknown(
|
||||
'Failed to execute command: ${e.toString()}',
|
||||
exception: Exception(stackTrace.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<T>> executeCommandWithResult<T>({
|
||||
required String endpoint,
|
||||
required Serializable command,
|
||||
required T Function(Map<String, dynamic>) fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/command/$endpoint');
|
||||
final headers = _buildHeaders();
|
||||
|
||||
final response = await _httpClient
|
||||
.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(command.toJson()),
|
||||
)
|
||||
.timeout(config.timeout);
|
||||
|
||||
return _handleResponse<T>(response, fromJson);
|
||||
} on TimeoutException {
|
||||
return Result.error(ApiError.timeout());
|
||||
} catch (e, stackTrace) {
|
||||
return Result.error(
|
||||
ApiError.unknown(
|
||||
'Failed to execute command with result: ${e.toString()}',
|
||||
exception: Exception(stackTrace.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<String>> uploadFile({
|
||||
required String endpoint,
|
||||
required String filePath,
|
||||
required String fieldName,
|
||||
Map<String, String>? additionalFields,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/command/$endpoint');
|
||||
final request = http.MultipartRequest('POST', url)
|
||||
..headers.addAll(_buildHeaders())
|
||||
..files.add(await http.MultipartFile.fromPath(fieldName, filePath));
|
||||
|
||||
if (additionalFields != null) {
|
||||
request.fields.addAll(additionalFields);
|
||||
}
|
||||
|
||||
final response = await request.send().timeout(config.timeout);
|
||||
final responseBody = await response.stream.bytesToString();
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return Result.success(responseBody);
|
||||
} else {
|
||||
return _parseErrorFromString(responseBody, response.statusCode);
|
||||
}
|
||||
} on TimeoutException {
|
||||
return Result.error(ApiError.timeout());
|
||||
} catch (e, stackTrace) {
|
||||
return Result.error(
|
||||
ApiError.unknown(
|
||||
'Failed to upload file: ${e.toString()}',
|
||||
exception: Exception(stackTrace.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _buildHeaders() {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...config.defaultHeaders,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
Result<T> _handleResponse<T>(
|
||||
http.Response response,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
try {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
if (response.body.isEmpty) {
|
||||
return Result.success(null as T);
|
||||
}
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return Result.success(fromJson(data));
|
||||
} else {
|
||||
return _handleErrorResponse(response);
|
||||
}
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ApiError.unknown('Failed to parse response: ${e.toString()}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Result<PaginatedResult<T>> _handlePaginatedResponse<T>(
|
||||
http.Response response,
|
||||
T Function(Map<String, dynamic>) itemFromJson,
|
||||
int page,
|
||||
int pageSize,
|
||||
) {
|
||||
try {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (data['items'] as List?)
|
||||
?.map((item) => itemFromJson(item as Map<String, dynamic>))
|
||||
.toList() ?? [];
|
||||
final totalCount = data['totalCount'] as int? ?? items.length;
|
||||
|
||||
return Result.success(
|
||||
PaginatedResult(
|
||||
items: items,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
totalCount: totalCount,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return _handleErrorResponse(response);
|
||||
}
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ApiError.unknown('Failed to parse paginated response: ${e.toString()}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Result<Never> _handleErrorResponse(http.Response response) {
|
||||
try {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final message = data['message'] as String? ?? 'An error occurred';
|
||||
|
||||
if (response.statusCode == 422) {
|
||||
final errors = data['errors'] as Map<String, dynamic>?;
|
||||
final details = errors?.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
(value as List?)?.map((e) => e.toString()).toList() ?? [],
|
||||
),
|
||||
);
|
||||
return Result.error(
|
||||
ApiError.validation(message, details),
|
||||
);
|
||||
}
|
||||
|
||||
return Result.error(
|
||||
ApiError.http(statusCode: response.statusCode, message: message),
|
||||
);
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ApiError.http(
|
||||
statusCode: response.statusCode,
|
||||
message: response.reasonPhrase ?? 'Unknown error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Result<Never> _parseErrorFromString(String body, int statusCode) {
|
||||
try {
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
final message = data['message'] as String? ?? 'An error occurred';
|
||||
return Result.error(
|
||||
ApiError.http(statusCode: statusCode, message: message),
|
||||
);
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ApiError.http(statusCode: statusCode, message: 'Unknown error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void close() {
|
||||
_httpClient.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
class ApiClientConfig {
|
||||
final String baseUrl;
|
||||
final Duration timeout;
|
||||
final Map<String, String> defaultHeaders;
|
||||
|
||||
const ApiClientConfig({
|
||||
required this.baseUrl,
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
this.defaultHeaders = const {},
|
||||
});
|
||||
|
||||
static const ApiClientConfig development = ApiClientConfig(
|
||||
baseUrl: 'https://api-route.goutezplanb.com',
|
||||
timeout: Duration(seconds: 30),
|
||||
);
|
||||
|
||||
static const ApiClientConfig production = ApiClientConfig(
|
||||
baseUrl: 'https://api-route.goutezplanb.com',
|
||||
timeout: Duration(seconds: 30),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
abstract interface class Serializable {
|
||||
Map<String, Object?> toJson();
|
||||
}
|
||||
|
||||
enum ApiErrorType {
|
||||
network,
|
||||
timeout,
|
||||
validation,
|
||||
http,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class ApiError {
|
||||
final ApiErrorType type;
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final Map<String, List<String>>? details;
|
||||
final Exception? originalException;
|
||||
|
||||
const ApiError({
|
||||
required this.type,
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.details,
|
||||
this.originalException,
|
||||
});
|
||||
|
||||
factory ApiError.network(String message) => ApiError(
|
||||
type: ApiErrorType.network,
|
||||
message: message,
|
||||
);
|
||||
|
||||
factory ApiError.timeout() => const ApiError(
|
||||
type: ApiErrorType.timeout,
|
||||
message: 'Request timeout',
|
||||
);
|
||||
|
||||
factory ApiError.validation(String message, Map<String, List<String>>? details) => ApiError(
|
||||
type: ApiErrorType.validation,
|
||||
message: message,
|
||||
details: details,
|
||||
);
|
||||
|
||||
factory ApiError.http({
|
||||
required int statusCode,
|
||||
required String message,
|
||||
}) => ApiError(
|
||||
type: ApiErrorType.http,
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
factory ApiError.unknown(String message, {Exception? exception}) => ApiError(
|
||||
type: ApiErrorType.unknown,
|
||||
message: message,
|
||||
originalException: exception,
|
||||
);
|
||||
}
|
||||
|
||||
sealed class Result<T> {
|
||||
const Result();
|
||||
|
||||
factory Result.success(T data) => Success<T>(data);
|
||||
|
||||
factory Result.error(ApiError error) => Error<T>(error);
|
||||
|
||||
R when<R>({
|
||||
required R Function(T data) success,
|
||||
required R Function(ApiError error) onError,
|
||||
}) {
|
||||
return switch (this) {
|
||||
Success<T>(:final data) => success(data),
|
||||
Error<T>(:final error) => onError(error),
|
||||
};
|
||||
}
|
||||
|
||||
R? whenSuccess<R>(R Function(T data) fn) {
|
||||
return switch (this) {
|
||||
Success<T>(:final data) => fn(data),
|
||||
Error<T>() => null,
|
||||
};
|
||||
}
|
||||
|
||||
R? whenError<R>(R Function(ApiError error) fn) {
|
||||
return switch (this) {
|
||||
Success<T>() => null,
|
||||
Error<T>(:final error) => fn(error),
|
||||
};
|
||||
}
|
||||
|
||||
bool get isSuccess => this is Success<T>;
|
||||
bool get isError => this is Error<T>;
|
||||
|
||||
T? getOrNull() => whenSuccess((data) => data);
|
||||
ApiError? getErrorOrNull() => whenError((error) => error);
|
||||
}
|
||||
|
||||
final class Success<T> extends Result<T> {
|
||||
final T data;
|
||||
|
||||
const Success(this.data);
|
||||
}
|
||||
|
||||
final class Error<T> extends Result<T> {
|
||||
final ApiError error;
|
||||
|
||||
const Error(this.error);
|
||||
}
|
||||
|
||||
class PaginatedResult<T> {
|
||||
final List<T> items;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int totalCount;
|
||||
|
||||
const PaginatedResult({
|
||||
required this.items,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
required this.totalCount,
|
||||
});
|
||||
|
||||
int get totalPages => (totalCount / pageSize).ceil();
|
||||
bool get hasNextPage => page < totalPages;
|
||||
}
|
||||
|
||||
enum FilterOperator {
|
||||
equals('eq'),
|
||||
notEquals('neq'),
|
||||
greaterThan('gt'),
|
||||
greaterThanOrEqual('gte'),
|
||||
lessThan('lt'),
|
||||
lessThanOrEqual('lte'),
|
||||
contains('contains'),
|
||||
startsWith('startsWith'),
|
||||
endsWith('endsWith'),
|
||||
in_('in');
|
||||
|
||||
final String operator;
|
||||
const FilterOperator(this.operator);
|
||||
}
|
||||
|
||||
class FilterCriteria implements Serializable {
|
||||
final String field;
|
||||
final FilterOperator operator;
|
||||
final Object? value;
|
||||
|
||||
FilterCriteria({
|
||||
required this.field,
|
||||
required this.operator,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {
|
||||
'field': field,
|
||||
'operator': operator.operator,
|
||||
'value': value,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user