Files
TimeTracker/mobile/lib/data/api/api_client.dart
T
Dries Peeters da85aedefb feat(mobile): add data layer, OTLP telemetry, and CI build fixes
Implement the missing Flutter data layer so release builds compile: Dio ApiClient for /api/v1 (timer, time entries, projects, tasks, finance, time-off, users/me), JSON models, Hive LocalStorage, and offline SyncService queue.

Add OpenTelemetry (opentelemetry package) with initMobileOpenTelemetry() reading OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_TOKEN via --dart-define, matching server OTLP base URL and Basic auth behavior. Instrument login token validation, timer start/stop, and sync pending.

Fix SyncUseCase to import storage SyncService, use trusted insecure hosts, and call syncAll().

GitHub Actions (build-mobile.yml, cd-release.yml): run flutter test; pass OTLP secrets into flutter build apk/appbundle/ios; switch iOS CI to release simulator builds and package build/ios/iphonesimulator/Runner.app to avoid requiring an Apple Development Team for generic device builds.

.gitignore: allow tracking mobile/lib/data/ despite the repo-wide data/ ignore rule.
2026-03-28 18:01:10 +01:00

419 lines
13 KiB
Dart

import 'package:dio/dio.dart';
import 'package:timetracker_mobile/utils/ssl/ssl_utils.dart';
/// HTTP client for TimeTracker `/api/v1` (Bearer token after login).
class ApiClient {
ApiClient({
required String baseUrl,
Set<String>? trustedInsecureHosts,
}) : _trusted = trustedInsecureHosts ?? {},
_dio = Dio() {
var normalized = baseUrl.trim();
if (!normalized.endsWith('/')) {
normalized = '$normalized/';
}
_baseUrl = normalized;
_dio.options = BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
validateStatus: (_) => true,
);
configureDioTrustedHosts(_dio, _trusted);
}
final Dio _dio;
final Set<String> _trusted;
late final String _baseUrl;
String get baseUrl => _baseUrl;
Future<void> setAuthToken(String token) async {
_dio.options.headers['Authorization'] = 'Bearer $token';
}
Future<Response<dynamic>> validateTokenRaw() {
return _dio.get<dynamic>('/api/v1/timer/status');
}
Future<Map<String, dynamic>> getUsersMe() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/users/me');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getCurrentUser() async {
final me = await getUsersMe();
final u = me['user'];
if (u is Map<String, dynamic>) return u;
if (u is Map) return Map<String, dynamic>.from(u);
return {};
}
Future<Map<String, dynamic>> getTimerStatus() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/timer/status');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> startTimer({
required int projectId,
int? taskId,
String? notes,
int? templateId,
}) async {
final body = <String, dynamic>{
'project_id': projectId,
if (taskId != null) 'task_id': taskId,
if (notes != null) 'notes': notes,
if (templateId != null) 'template_id': templateId,
};
final res = await _dio.post<Map<String, dynamic>>('/api/v1/timer/start', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> stopTimer() async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/timer/stop');
final code = res.statusCode ?? 0;
if (code >= 200 && code < 300) {
return Map<String, dynamic>.from(res.data ?? {});
}
throw DioException(
requestOptions: res.requestOptions,
response: res,
type: DioExceptionType.badResponse,
);
}
Future<Map<String, dynamic>> getTimeEntries({
int? projectId,
String? startDate,
String? endDate,
bool? billable,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/time-entries',
queryParameters: <String, dynamic>{
if (projectId != null) 'project_id': projectId,
if (startDate != null) 'start_date': startDate,
if (endDate != null) 'end_date': endDate,
if (billable != null) 'billable': billable.toString(),
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimeEntry(int entryId) async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-entries/$entryId');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> createTimeEntry({
required int projectId,
int? taskId,
required String startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
final body = <String, dynamic>{
'project_id': projectId,
'start_time': startTime,
if (taskId != null) 'task_id': taskId,
if (endTime != null) 'end_time': endTime,
if (notes != null) 'notes': notes,
if (tags != null) 'tags': tags,
if (billable != null) 'billable': billable,
};
final res = await _dio.post<Map<String, dynamic>>('/api/v1/time-entries', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> updateTimeEntry(
int entryId, {
int? projectId,
int? taskId,
String? startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
final body = <String, dynamic>{
if (projectId != null) 'project_id': projectId,
if (taskId != null) 'task_id': taskId,
if (startTime != null) 'start_time': startTime,
if (endTime != null) 'end_time': endTime,
if (notes != null) 'notes': notes,
if (tags != null) 'tags': tags,
if (billable != null) 'billable': billable,
};
final res = await _dio.patch<Map<String, dynamic>>('/api/v1/time-entries/$entryId', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<void> deleteTimeEntry(int entryId) async {
final res = await _dio.delete<Map<String, dynamic>>('/api/v1/time-entries/$entryId');
_throwIfError(res);
}
Future<Map<String, dynamic>> getProjects({
String? status,
int? clientId,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/projects',
queryParameters: <String, dynamic>{
if (status != null) 'status': status,
if (clientId != null) 'client_id': clientId,
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getProject(int projectId) async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/projects/$projectId');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTasks({
int? projectId,
String? status,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/tasks',
queryParameters: <String, dynamic>{
if (projectId != null) 'project_id': projectId,
if (status != null) 'status': status,
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTask(int taskId) async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/tasks/$taskId');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getClients({
String? status,
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/clients',
queryParameters: <String, dynamic>{
if (status != null) 'status': status,
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getInvoices({
int? page,
int? perPage,
String? status,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/invoices',
queryParameters: <String, dynamic>{
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
if (status != null) 'status': status,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getExpenses({
int? page,
int? perPage,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/expenses',
queryParameters: <String, dynamic>{
if (page != null) 'page': page,
if (perPage != null) 'per_page': perPage,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> createExpense(Map<String, dynamic> body) async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/expenses', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> createInvoice(Map<String, dynamic> body) async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/invoices', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> updateInvoice(int invoiceId, Map<String, dynamic> body) async {
final res = await _dio.patch<Map<String, dynamic>>('/api/v1/invoices/$invoiceId', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimesheetPeriods({
String? startDate,
String? endDate,
String? status,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/timesheet-periods',
queryParameters: <String, dynamic>{
if (startDate != null) 'start_date': startDate,
if (endDate != null) 'end_date': endDate,
if (status != null) 'status': status,
},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getCapacityReport({
required String startDate,
required String endDate,
}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/v1/reports/capacity',
queryParameters: {'start_date': startDate, 'end_date': endDate},
);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getLeaveTypes() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-off/leave-types');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimeOffRequests() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-off/requests');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<Map<String, dynamic>> getTimeOffBalances() async {
final res = await _dio.get<Map<String, dynamic>>('/api/v1/time-off/balances');
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<void> submitTimesheetPeriod(int periodId) async {
final res = await _dio.post<Map<String, dynamic>>('/api/v1/timesheet-periods/$periodId/submit');
_throwIfError(res);
}
Future<void> approveTimesheetPeriod(int periodId, {String? comment}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/timesheet-periods/$periodId/approve',
data: {if (comment != null) 'comment': comment},
);
_throwIfError(res);
}
Future<void> rejectTimesheetPeriod(int periodId, {String? reason}) async {
final r = (reason ?? 'Rejected').trim();
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/timesheet-periods/$periodId/reject',
data: {'reason': r.isEmpty ? 'Rejected' : r},
);
_throwIfError(res);
}
Future<void> deleteTimesheetPeriod(int periodId) async {
final res = await _dio.delete<Map<String, dynamic>>('/api/v1/timesheet-periods/$periodId');
_throwIfError(res);
}
Future<Map<String, dynamic>> createTimeOffRequest({
required int leaveTypeId,
required String startDate,
required String endDate,
double? requestedHours,
String? comment,
}) async {
final body = <String, dynamic>{
'leave_type_id': leaveTypeId,
'start_date': startDate,
'end_date': endDate,
if (requestedHours != null) 'requested_hours': requestedHours,
if (comment != null && comment.isNotEmpty) 'comment': comment,
};
final res = await _dio.post<Map<String, dynamic>>('/api/v1/time-off/requests', data: body);
_throwIfError(res);
return Map<String, dynamic>.from(res.data ?? {});
}
Future<void> approveTimeOffRequest(int requestId, {String? comment}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/time-off/requests/$requestId/approve',
data: {if (comment != null) 'comment': comment},
);
_throwIfError(res);
}
Future<void> rejectTimeOffRequest(int requestId, {String? comment}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/v1/time-off/requests/$requestId/reject',
data: {if (comment != null) 'comment': comment},
);
_throwIfError(res);
}
Future<void> deleteTimeOffRequest(int requestId) async {
final res = await _dio.delete<Map<String, dynamic>>('/api/v1/time-off/requests/$requestId');
_throwIfError(res);
}
void _throwIfError(Response<dynamic> res) {
final code = res.statusCode ?? 0;
if (code >= 200 && code < 300) return;
final data = res.data;
String msg = 'HTTP $code';
if (data is Map) {
final err = data['error'] ?? data['message'];
if (err != null) msg = err.toString();
}
throw DioException(
requestOptions: res.requestOptions,
response: res,
type: DioExceptionType.badResponse,
message: msg,
);
}
}