mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 01:49:35 -05:00
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.
This commit is contained in:
@@ -45,14 +45,19 @@ jobs:
|
||||
working-directory: mobile
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
# Skip tests for now - mobile app is incomplete (tests exist but lib/ source code is missing)
|
||||
# - name: Run tests
|
||||
# working-directory: mobile
|
||||
# run: flutter test
|
||||
- name: Run tests
|
||||
working-directory: mobile
|
||||
run: flutter test
|
||||
|
||||
- name: Build APK
|
||||
working-directory: mobile
|
||||
run: flutter build apk --release
|
||||
env:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
|
||||
run: |
|
||||
flutter build apk --release \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -92,43 +97,34 @@ jobs:
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_launcher_icons -f flutter_launcher_icons_ios.yaml
|
||||
|
||||
- name: Configure iOS project for device build without code signing
|
||||
# Simulator build avoids Apple Development Team / provisioning (device ipa still needs signing locally).
|
||||
- name: Run tests
|
||||
working-directory: mobile
|
||||
run: flutter test
|
||||
|
||||
- name: Build iOS (simulator)
|
||||
working-directory: mobile
|
||||
env:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
|
||||
run: |
|
||||
# Disable code signing requirements in Xcode project
|
||||
sed -i '' 's/DEVELOPMENT_TEAM = .*;/DEVELOPMENT_TEAM = "";/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGN_IDENTITY = .*;/CODE_SIGN_IDENTITY = "";/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGN_IDENTITY\[sdk=iphoneos\*\] = .*;/CODE_SIGN_IDENTITY[sdk=iphoneos*] = "";/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGNING_REQUIRED = .*;/CODE_SIGNING_REQUIRED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGNING_ALLOWED = .*;/CODE_SIGNING_ALLOWED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
# Also set for all configurations
|
||||
sed -i '' 's/ProvisioningStyle = Automatic;/ProvisioningStyle = Manual;/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
|
||||
# Skip tests for now - mobile app is incomplete (tests exist but lib/ source code is missing)
|
||||
# - name: Run tests
|
||||
# working-directory: mobile
|
||||
# run: flutter test
|
||||
|
||||
- name: Build iOS
|
||||
working-directory: mobile
|
||||
run: flutter build ios --release --no-codesign
|
||||
flutter build ios --release --simulator \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
|
||||
|
||||
- name: Create iOS archive
|
||||
if: success()
|
||||
working-directory: mobile
|
||||
run: |
|
||||
mkdir -p dist
|
||||
# Package the built iOS app (IPA would require codesigning, so we'll package the .app)
|
||||
if [ -d "build/ios/iphoneos/Runner.app" ]; then
|
||||
cd build/ios/iphoneos
|
||||
if [ -d "build/ios/iphonesimulator/Runner.app" ]; then
|
||||
cd build/ios/iphonesimulator
|
||||
zip -r ../../../dist/TimeTracker-iOS.zip Runner.app
|
||||
cd ../../..
|
||||
else
|
||||
echo "Warning: Runner.app not found at build/ios/iphoneos/Runner.app"
|
||||
echo "Listing build/ios directory:"
|
||||
ls -la build/ios/ || echo "build/ios directory does not exist"
|
||||
echo "Listing build directory:"
|
||||
ls -la build/ || echo "build directory does not exist"
|
||||
echo "Warning: Runner.app not found at build/ios/iphonesimulator/Runner.app"
|
||||
ls -la build/ios/ || true
|
||||
ls -la build/ || true
|
||||
fi
|
||||
|
||||
- name: Upload iOS build
|
||||
|
||||
@@ -960,13 +960,29 @@ jobs:
|
||||
working-directory: mobile
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Run mobile tests
|
||||
working-directory: mobile
|
||||
run: flutter test
|
||||
|
||||
- name: Build APK
|
||||
working-directory: mobile
|
||||
run: flutter build apk --release
|
||||
env:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
|
||||
run: |
|
||||
flutter build apk --release \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
|
||||
|
||||
- name: Build App Bundle
|
||||
working-directory: mobile
|
||||
run: flutter build appbundle --release
|
||||
env:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
|
||||
run: |
|
||||
flutter build appbundle --release \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Android APK
|
||||
@@ -1022,42 +1038,36 @@ jobs:
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_launcher_icons -f flutter_launcher_icons_ios.yaml
|
||||
|
||||
- name: Configure iOS project for device build without code signing
|
||||
- name: Run mobile tests
|
||||
working-directory: mobile
|
||||
run: |
|
||||
# Disable code signing requirements in Xcode project
|
||||
sed -i '' 's/DEVELOPMENT_TEAM = .*;/DEVELOPMENT_TEAM = "";/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGN_IDENTITY = .*;/CODE_SIGN_IDENTITY = "";/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGN_IDENTITY\[sdk=iphoneos\*\] = .*;/CODE_SIGN_IDENTITY[sdk=iphoneos*] = "";/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGNING_REQUIRED = .*;/CODE_SIGNING_REQUIRED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/CODE_SIGNING_ALLOWED = .*;/CODE_SIGNING_ALLOWED = NO;/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
# Also set for all configurations
|
||||
sed -i '' 's/ProvisioningStyle = Automatic;/ProvisioningStyle = Manual;/g' ios/Runner.xcodeproj/project.pbxproj
|
||||
run: flutter test
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
- name: Build iOS (simulator)
|
||||
working-directory: mobile
|
||||
run: flutter build ios --release --no-codesign
|
||||
env:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_TOKEN: ${{ secrets.OTEL_EXPORTER_OTLP_TOKEN }}
|
||||
run: |
|
||||
flutter build ios --release --simulator \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" \
|
||||
--dart-define=OTEL_EXPORTER_OTLP_TOKEN="${OTEL_EXPORTER_OTLP_TOKEN:-}"
|
||||
|
||||
- name: Create iOS archive
|
||||
if: success()
|
||||
working-directory: mobile
|
||||
run: |
|
||||
mkdir -p dist
|
||||
# Package the built iOS app (IPA would require codesigning, so we'll package the .app)
|
||||
if [ -d "build/ios/iphoneos/Runner.app" ]; then
|
||||
cd build/ios/iphoneos
|
||||
if [ -d "build/ios/iphonesimulator/Runner.app" ]; then
|
||||
cd build/ios/iphonesimulator
|
||||
zip -r ../../../dist/TimeTracker-iOS-${{ needs.determine-version.outputs.version }}.zip Runner.app
|
||||
cd ../../..
|
||||
echo "✅ iOS archive created successfully"
|
||||
echo "✅ iOS simulator archive created successfully"
|
||||
ls -lh dist/
|
||||
else
|
||||
echo "❌ ERROR: Runner.app not found at build/ios/iphoneos/Runner.app"
|
||||
echo "Listing build/ios directory:"
|
||||
ls -la build/ios/ || echo "build/ios directory does not exist"
|
||||
echo "Listing build directory:"
|
||||
ls -la build/ || echo "build directory does not exist"
|
||||
echo "Searching for Runner.app:"
|
||||
find build -name "Runner.app" -type d 2>/dev/null || echo "Runner.app not found anywhere"
|
||||
echo "❌ ERROR: Runner.app not found at build/ios/iphonesimulator/Runner.app"
|
||||
ls -la build/ios/ || true
|
||||
ls -la build/ || true
|
||||
find build -name "Runner.app" -type d 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -131,6 +131,9 @@ dmypy.json
|
||||
|
||||
# Application specific
|
||||
data/
|
||||
# Flutter app source lives under mobile/lib/data/ (do not treat as runtime data dir)
|
||||
!mobile/lib/data/
|
||||
!mobile/lib/data/**
|
||||
logs/
|
||||
backups/
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:opentelemetry/api.dart' as otel_api;
|
||||
import 'package:opentelemetry/sdk.dart' as otel_sdk;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
bool _mobileOtelInitialized = false;
|
||||
|
||||
/// OTLP endpoint and token from `--dart-define=OTEL_EXPORTER_OTLP_*` (CI parity with main app).
|
||||
/// Values are embedded in release binaries—avoid for untrusted distribution.
|
||||
Future<void> initMobileOpenTelemetry() async {
|
||||
const endpoint = String.fromEnvironment('OTEL_EXPORTER_OTLP_ENDPOINT');
|
||||
const token = String.fromEnvironment('OTEL_EXPORTER_OTLP_TOKEN');
|
||||
if (endpoint.isEmpty || token.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
var base = endpoint.trim();
|
||||
if (base.endsWith('/')) {
|
||||
base = base.substring(0, base.length - 1);
|
||||
}
|
||||
if (base.endsWith('/v1/logs')) {
|
||||
base = base.substring(0, base.length - '/v1/logs'.length);
|
||||
} else {
|
||||
final idx = base.lastIndexOf('/v1/logs');
|
||||
if (idx != -1) {
|
||||
base = base.substring(0, idx);
|
||||
}
|
||||
}
|
||||
|
||||
final traceUri = Uri.parse('$base/v1/traces');
|
||||
final authHeader = buildOtlpAuthHeader(token);
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final resource = otel_sdk.Resource([
|
||||
otel_api.Attribute.fromString('service.name', 'timetracker-mobile'),
|
||||
otel_api.Attribute.fromString('service.version', info.version),
|
||||
]);
|
||||
|
||||
final exporter = otel_sdk.CollectorExporter(
|
||||
traceUri,
|
||||
headers: {'Authorization': authHeader},
|
||||
);
|
||||
|
||||
final provider = otel_sdk.TracerProviderBase(
|
||||
resource: resource,
|
||||
processors: [
|
||||
otel_sdk.BatchSpanProcessor(exporter),
|
||||
],
|
||||
);
|
||||
|
||||
otel_api.registerGlobalTracerProvider(provider);
|
||||
_mobileOtelInitialized = true;
|
||||
}
|
||||
|
||||
/// Matches [app.telemetry.service._build_otlp_auth_header] (Basic / instance:token).
|
||||
String buildOtlpAuthHeader(String token) {
|
||||
final value = token.trim();
|
||||
if (value.toLowerCase().startsWith('basic ')) {
|
||||
return value;
|
||||
}
|
||||
if (value.contains(':')) {
|
||||
final encoded = base64Encode(utf8.encode(value));
|
||||
return 'Basic $encoded';
|
||||
}
|
||||
return 'Basic $value';
|
||||
}
|
||||
|
||||
/// No-op if OpenTelemetry was not initialized.
|
||||
otel_api.Tracer? get mobileTracerOrNull {
|
||||
if (!_mobileOtelInitialized) return null;
|
||||
return otel_api.globalTracerProvider.getTracer('timetracker.mobile');
|
||||
}
|
||||
|
||||
Future<T> runMobileSpan<T>(
|
||||
String name,
|
||||
Future<T> Function() body, {
|
||||
Map<String, String> attributes = const {},
|
||||
}) async {
|
||||
final tracer = mobileTracerOrNull;
|
||||
if (tracer == null) {
|
||||
return body();
|
||||
}
|
||||
final span = tracer.startSpan(name);
|
||||
for (final e in attributes.entries) {
|
||||
span.setAttribute(otel_api.Attribute.fromString(e.key, e.value));
|
||||
}
|
||||
try {
|
||||
return await body();
|
||||
} catch (e, st) {
|
||||
span.setStatus(otel_api.StatusCode.error, e.toString());
|
||||
span.recordException(e, stackTrace: st);
|
||||
rethrow;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
class Project {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? client;
|
||||
final String status;
|
||||
final bool billable;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const Project({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.client,
|
||||
this.status = 'active',
|
||||
this.billable = true,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory Project.fromJson(Map<String, dynamic> json) {
|
||||
return Project(
|
||||
id: (json['id'] as num).toInt(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
client: json['client']?.toString(),
|
||||
status: (json['status'] ?? 'active').toString(),
|
||||
billable: json['billable'] == true,
|
||||
createdAt: _parseDt(json['created_at']),
|
||||
updatedAt: _parseDt(json['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'client': client,
|
||||
'status': status,
|
||||
'billable': billable,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
'updated_at': updatedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
static DateTime? _parseDt(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is DateTime) return v;
|
||||
return DateTime.tryParse(v.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
class Task {
|
||||
final int id;
|
||||
final int projectId;
|
||||
final String name;
|
||||
final String status;
|
||||
final String? priority;
|
||||
final int? createdBy;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const Task({
|
||||
required this.id,
|
||||
required this.projectId,
|
||||
required this.name,
|
||||
this.status = 'todo',
|
||||
this.priority,
|
||||
this.createdBy,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory Task.fromJson(Map<String, dynamic> json) {
|
||||
return Task(
|
||||
id: (json['id'] as num).toInt(),
|
||||
projectId: (json['project_id'] as num?)?.toInt() ?? 0,
|
||||
name: (json['name'] ?? '').toString(),
|
||||
status: (json['status'] ?? 'todo').toString(),
|
||||
priority: json['priority']?.toString(),
|
||||
createdBy: (json['created_by'] as num?)?.toInt(),
|
||||
createdAt: _parseDt(json['created_at']),
|
||||
updatedAt: _parseDt(json['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'project_id': projectId,
|
||||
'name': name,
|
||||
'status': status,
|
||||
'priority': priority,
|
||||
'created_by': createdBy,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
'updated_at': updatedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
static DateTime? _parseDt(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is DateTime) return v;
|
||||
return DateTime.tryParse(v.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
class TimeEntry {
|
||||
final int id;
|
||||
final int userId;
|
||||
final int? projectId;
|
||||
final int? taskId;
|
||||
final DateTime? startTime;
|
||||
final DateTime? endTime;
|
||||
final int? durationSeconds;
|
||||
final String? notes;
|
||||
final String? tags;
|
||||
final String source;
|
||||
final bool billable;
|
||||
final bool paid;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const TimeEntry({
|
||||
required this.id,
|
||||
this.userId = 0,
|
||||
this.projectId,
|
||||
this.taskId,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.durationSeconds,
|
||||
this.notes,
|
||||
this.tags,
|
||||
this.source = 'manual',
|
||||
this.billable = true,
|
||||
this.paid = false,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory TimeEntry.fromJson(Map<String, dynamic> json) {
|
||||
return TimeEntry(
|
||||
id: (json['id'] as num).toInt(),
|
||||
userId: (json['user_id'] as num?)?.toInt() ?? 0,
|
||||
projectId: (json['project_id'] as num?)?.toInt(),
|
||||
taskId: (json['task_id'] as num?)?.toInt(),
|
||||
startTime: _parseDt(json['start_time']),
|
||||
endTime: _parseDt(json['end_time']),
|
||||
durationSeconds: (json['duration_seconds'] as num?)?.toInt(),
|
||||
notes: json['notes']?.toString(),
|
||||
tags: json['tags']?.toString(),
|
||||
source: (json['source'] ?? 'manual').toString(),
|
||||
billable: json['billable'] != false,
|
||||
paid: json['paid'] == true,
|
||||
createdAt: _parseDt(json['created_at']),
|
||||
updatedAt: _parseDt(json['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'project_id': projectId,
|
||||
'task_id': taskId,
|
||||
'start_time': startTime?.toIso8601String(),
|
||||
'end_time': endTime?.toIso8601String(),
|
||||
'duration_seconds': durationSeconds,
|
||||
'notes': notes,
|
||||
'tags': tags,
|
||||
'source': source,
|
||||
'billable': billable,
|
||||
'paid': paid,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
'updated_at': updatedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
String get formattedDuration {
|
||||
final secs = durationSeconds;
|
||||
if (secs == null || secs <= 0) {
|
||||
if (startTime != null && endTime != null) {
|
||||
final d = endTime!.difference(startTime!);
|
||||
return _formatSeconds(d.inSeconds);
|
||||
}
|
||||
return '0m';
|
||||
}
|
||||
return _formatSeconds(secs);
|
||||
}
|
||||
|
||||
static String _formatSeconds(int totalSeconds) {
|
||||
final h = totalSeconds ~/ 3600;
|
||||
final m = (totalSeconds % 3600) ~/ 60;
|
||||
if (h > 0) {
|
||||
return m > 0 ? '${h}h ${m}m' : '${h}h';
|
||||
}
|
||||
if (m > 0) return '${m}m';
|
||||
final s = totalSeconds % 60;
|
||||
return '${s}s';
|
||||
}
|
||||
|
||||
static DateTime? _parseDt(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is DateTime) return v;
|
||||
return DateTime.tryParse(v.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class TimeEntryRequirements {
|
||||
final bool requireTask;
|
||||
final bool requireDescription;
|
||||
final int descriptionMinLength;
|
||||
|
||||
const TimeEntryRequirements({
|
||||
this.requireTask = false,
|
||||
this.requireDescription = false,
|
||||
this.descriptionMinLength = 0,
|
||||
});
|
||||
|
||||
factory TimeEntryRequirements.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null || json.isEmpty) {
|
||||
return const TimeEntryRequirements();
|
||||
}
|
||||
return TimeEntryRequirements(
|
||||
requireTask: json['require_task'] == true,
|
||||
requireDescription: json['require_description'] == true,
|
||||
descriptionMinLength: (json['description_min_length'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Active timer row from `/api/v1/timer/status` (same fields as server time entry JSON).
|
||||
@immutable
|
||||
class Timer {
|
||||
final int id;
|
||||
final int userId;
|
||||
final int? projectId;
|
||||
final int? taskId;
|
||||
final DateTime startTime;
|
||||
final String? notes;
|
||||
|
||||
const Timer({
|
||||
required this.id,
|
||||
this.userId = 0,
|
||||
this.projectId,
|
||||
this.taskId,
|
||||
required this.startTime,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
factory Timer.fromJson(Map<String, dynamic> json) {
|
||||
final startRaw = json['start_time'];
|
||||
final start = startRaw is DateTime
|
||||
? startRaw
|
||||
: DateTime.tryParse(startRaw?.toString() ?? '') ?? DateTime.now();
|
||||
return Timer(
|
||||
id: (json['id'] as num).toInt(),
|
||||
userId: (json['user_id'] as num?)?.toInt() ?? 0,
|
||||
projectId: (json['project_id'] as num?)?.toInt(),
|
||||
taskId: (json['task_id'] as num?)?.toInt(),
|
||||
startTime: start,
|
||||
notes: json['notes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'project_id': projectId,
|
||||
'task_id': taskId,
|
||||
'start_time': startTime.toIso8601String(),
|
||||
'notes': notes,
|
||||
};
|
||||
|
||||
String get formattedElapsed {
|
||||
final d = DateTime.now().difference(startTime);
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes.remainder(60);
|
||||
final s = d.inSeconds.remainder(60);
|
||||
return '${h.toString().padLeft(2, '0')}:'
|
||||
'${m.toString().padLeft(2, '0')}:'
|
||||
'${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class UserPrefs {
|
||||
final String dateFormatKey;
|
||||
final String timeFormatKey;
|
||||
final String timezone;
|
||||
|
||||
const UserPrefs({
|
||||
this.dateFormatKey = 'YYYY-MM-DD',
|
||||
this.timeFormatKey = '24h',
|
||||
this.timezone = 'UTC',
|
||||
});
|
||||
|
||||
factory UserPrefs.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null || json.isEmpty) {
|
||||
return const UserPrefs();
|
||||
}
|
||||
return UserPrefs(
|
||||
dateFormatKey: (json['date_format'] ?? 'YYYY-MM-DD').toString(),
|
||||
timeFormatKey: (json['time_format'] ?? '24h').toString(),
|
||||
timezone: (json['timezone'] ?? 'UTC').toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:timetracker_mobile/data/models/timer.dart' as tt;
|
||||
import 'package:timetracker_mobile/data/models/time_entry.dart';
|
||||
|
||||
class LocalStorage {
|
||||
LocalStorage._();
|
||||
|
||||
static const _boxName = 'timetracker_local_v1';
|
||||
static const _kTimer = 'timer';
|
||||
static const _kEntries = 'time_entries';
|
||||
static const _kQueue = 'sync_queue';
|
||||
|
||||
static Box<String>? _box;
|
||||
|
||||
static Future<void> init() async {
|
||||
await Hive.initFlutter();
|
||||
_box = await Hive.openBox<String>(_boxName);
|
||||
}
|
||||
|
||||
static Box<String> get _b {
|
||||
final b = _box;
|
||||
if (b == null) {
|
||||
throw StateError('LocalStorage.init() was not called');
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
static Future<tt.Timer?> getTimer() async {
|
||||
final raw = _b.get(_kTimer);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return tt.Timer.fromJson(map);
|
||||
}
|
||||
|
||||
static Future<void> saveTimer(tt.Timer timer) async {
|
||||
await _b.put(_kTimer, jsonEncode(timer.toJson()));
|
||||
}
|
||||
|
||||
static Future<void> clearTimer() async {
|
||||
await _b.delete(_kTimer);
|
||||
}
|
||||
|
||||
static Future<List<TimeEntry>> getAllTimeEntries() async {
|
||||
final raw = _b.get(_kEntries);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.map((e) => TimeEntry.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<void> saveTimeEntry(TimeEntry entry) async {
|
||||
final all = await getAllTimeEntries();
|
||||
final idx = all.indexWhere((e) => e.id == entry.id);
|
||||
if (idx >= 0) {
|
||||
all[idx] = entry;
|
||||
} else {
|
||||
all.add(entry);
|
||||
}
|
||||
await _persistEntries(all);
|
||||
}
|
||||
|
||||
static Future<void> deleteTimeEntry(int entryId) async {
|
||||
final all = await getAllTimeEntries();
|
||||
all.removeWhere((e) => e.id == entryId);
|
||||
await _persistEntries(all);
|
||||
}
|
||||
|
||||
static Future<void> _persistEntries(List<TimeEntry> entries) async {
|
||||
await _b.put(
|
||||
_kEntries,
|
||||
jsonEncode(entries.map((e) => e.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getSyncQueue() async {
|
||||
final raw = _b.get(_kQueue);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list.map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
}
|
||||
|
||||
static Future<void> setSyncQueue(List<Map<String, dynamic>> queue) async {
|
||||
await _b.put(_kQueue, jsonEncode(queue));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:timetracker_mobile/data/api/api_client.dart';
|
||||
import 'package:timetracker_mobile/data/models/time_entry.dart';
|
||||
import 'package:timetracker_mobile/data/storage/local_storage.dart';
|
||||
|
||||
class SyncService {
|
||||
SyncService(this._api);
|
||||
|
||||
final ApiClient? _api;
|
||||
|
||||
static Future<void> queueCreateTimeEntry({
|
||||
required int projectId,
|
||||
int? taskId,
|
||||
required String startTime,
|
||||
String? endTime,
|
||||
String? notes,
|
||||
String? tags,
|
||||
bool? billable,
|
||||
}) async {
|
||||
final q = await LocalStorage.getSyncQueue();
|
||||
q.add({
|
||||
'op': 'create_time_entry',
|
||||
'project_id': projectId,
|
||||
if (taskId != null) 'task_id': taskId,
|
||||
'start_time': startTime,
|
||||
if (endTime != null) 'end_time': endTime,
|
||||
if (notes != null) 'notes': notes,
|
||||
if (tags != null) 'tags': tags,
|
||||
if (billable != null) 'billable': billable,
|
||||
});
|
||||
await LocalStorage.setSyncQueue(q);
|
||||
}
|
||||
|
||||
static Future<void> queueDeleteTimeEntry(int entryId) async {
|
||||
final q = await LocalStorage.getSyncQueue();
|
||||
q.add({
|
||||
'op': 'delete_time_entry',
|
||||
'entry_id': entryId,
|
||||
});
|
||||
await LocalStorage.setSyncQueue(q);
|
||||
}
|
||||
|
||||
Future<void> syncAll() async {
|
||||
await processQueue();
|
||||
await syncFromServer();
|
||||
}
|
||||
|
||||
Future<void> processQueue() async {
|
||||
final api = _api;
|
||||
if (api == null) return;
|
||||
|
||||
final q = await LocalStorage.getSyncQueue();
|
||||
final remaining = <Map<String, dynamic>>[];
|
||||
|
||||
for (final op in q) {
|
||||
final type = op['op']?.toString();
|
||||
try {
|
||||
if (type == 'create_time_entry') {
|
||||
await api.createTimeEntry(
|
||||
projectId: (op['project_id'] as num).toInt(),
|
||||
taskId: (op['task_id'] as num?)?.toInt(),
|
||||
startTime: op['start_time'].toString(),
|
||||
endTime: op['end_time']?.toString(),
|
||||
notes: op['notes']?.toString(),
|
||||
tags: op['tags']?.toString(),
|
||||
billable: op['billable'] as bool?,
|
||||
);
|
||||
} else if (type == 'delete_time_entry') {
|
||||
await api.deleteTimeEntry((op['entry_id'] as num).toInt());
|
||||
} else {
|
||||
remaining.add(op);
|
||||
}
|
||||
} catch (_) {
|
||||
remaining.add(op);
|
||||
}
|
||||
}
|
||||
|
||||
await LocalStorage.setSyncQueue(remaining);
|
||||
}
|
||||
|
||||
Future<void> syncFromServer() async {
|
||||
final api = _api;
|
||||
if (api == null) return;
|
||||
try {
|
||||
final res = await api.getTimeEntries(perPage: 200);
|
||||
final raw = res['time_entries'] as List<dynamic>? ?? [];
|
||||
for (final e in raw) {
|
||||
if (e is Map) {
|
||||
final entry = TimeEntry.fromJson(Map<String, dynamic>.from(e));
|
||||
await LocalStorage.saveTimeEntry(entry);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:timetracker_mobile/data/models/timer.dart';
|
||||
import 'package:timetracker_mobile/data/models/time_entry.dart';
|
||||
import 'package:timetracker_mobile/data/models/project.dart';
|
||||
import 'package:timetracker_mobile/data/models/task.dart';
|
||||
import 'package:timetracker_mobile/core/telemetry/mobile_otel.dart';
|
||||
import 'package:timetracker_mobile/data/storage/local_storage.dart';
|
||||
import 'package:timetracker_mobile/data/storage/sync_service.dart';
|
||||
|
||||
@@ -94,11 +95,15 @@ class TimeTrackingRepository {
|
||||
return timer;
|
||||
}
|
||||
|
||||
final response = await apiClient!.startTimer(
|
||||
projectId: projectId,
|
||||
taskId: taskId,
|
||||
notes: notes,
|
||||
templateId: templateId,
|
||||
final response = await runMobileSpan(
|
||||
'mobile.timer.start',
|
||||
() => apiClient!.startTimer(
|
||||
projectId: projectId,
|
||||
taskId: taskId,
|
||||
notes: notes,
|
||||
templateId: templateId,
|
||||
),
|
||||
attributes: {'project_id': '$projectId'},
|
||||
);
|
||||
final timer = Timer.fromJson(response['timer'] as Map<String, dynamic>);
|
||||
await LocalStorage.saveTimer(timer);
|
||||
@@ -114,7 +119,10 @@ class TimeTrackingRepository {
|
||||
throw Exception('Not connected to server');
|
||||
}
|
||||
try {
|
||||
final response = await apiClient!.stopTimer();
|
||||
final response = await runMobileSpan(
|
||||
'mobile.timer.stop',
|
||||
() => apiClient!.stopTimer(),
|
||||
);
|
||||
final entry = TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
|
||||
await LocalStorage.clearTimer();
|
||||
return entry;
|
||||
@@ -334,7 +342,12 @@ class TimeTrackingRepository {
|
||||
|
||||
/// Sync pending operations
|
||||
Future<void> syncPending() async {
|
||||
await _syncService?.syncAll();
|
||||
await runMobileSpan(
|
||||
'mobile.sync.pending',
|
||||
() async {
|
||||
await _syncService?.syncAll();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Project Operations ====================
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'dart:async';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../data/api/api_client.dart';
|
||||
import '../../data/local/database/sync_service.dart';
|
||||
import '../../utils/auth/auth_service.dart';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:timetracker_mobile/core/config/app_config.dart';
|
||||
import 'package:timetracker_mobile/data/api/api_client.dart';
|
||||
import 'package:timetracker_mobile/data/storage/sync_service.dart';
|
||||
import 'package:timetracker_mobile/utils/auth/auth_service.dart';
|
||||
|
||||
class SyncUseCase {
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
@@ -47,13 +48,12 @@ class SyncUseCase {
|
||||
return false;
|
||||
}
|
||||
|
||||
final apiClient = ApiClient(baseUrl: serverUrl);
|
||||
final trustedHosts = await AppConfig.getTrustedInsecureHosts();
|
||||
final apiClient = ApiClient(baseUrl: serverUrl, trustedInsecureHosts: trustedHosts);
|
||||
await apiClient.setAuthToken(token);
|
||||
|
||||
final syncService = SyncService(apiClient);
|
||||
|
||||
await syncService.processQueue();
|
||||
await syncService.syncFromServer();
|
||||
await syncService.syncAll();
|
||||
|
||||
return true;
|
||||
} catch (_) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:timetracker_mobile/core/constants/app_constants.dart';
|
||||
import 'package:timetracker_mobile/core/telemetry/mobile_otel.dart';
|
||||
import 'package:timetracker_mobile/core/theme/app_theme.dart';
|
||||
import 'package:timetracker_mobile/data/storage/local_storage.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/theme_mode_provider.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:timetracker_mobile/presentation/screens/home_screen.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await LocalStorage.init();
|
||||
await initMobileOpenTelemetry();
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: TimeTrackerApp(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:timetracker_mobile/core/config/app_config.dart';
|
||||
import 'package:timetracker_mobile/core/constants/app_constants.dart';
|
||||
import 'package:timetracker_mobile/core/telemetry/mobile_otel.dart';
|
||||
import 'package:timetracker_mobile/data/api/api_client.dart';
|
||||
import 'package:timetracker_mobile/utils/network/connection_diagnostics.dart';
|
||||
import 'package:timetracker_mobile/utils/ssl/certificate_error.dart';
|
||||
@@ -110,7 +111,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
|
||||
final apiClient = ApiClient(baseUrl: serverUrl, trustedInsecureHosts: trustedHosts);
|
||||
await apiClient.setAuthToken(token);
|
||||
final validationResponse = await apiClient.validateTokenRaw();
|
||||
final validationResponse = await runMobileSpan(
|
||||
'mobile.login.validate_token',
|
||||
() => apiClient.validateTokenRaw(),
|
||||
);
|
||||
final status = validationResponse.statusCode;
|
||||
if (status != 200) {
|
||||
setState(() {
|
||||
|
||||
@@ -42,6 +42,9 @@ dependencies:
|
||||
package_info_plus: ^8.0.0
|
||||
google_fonts: ^8.0.0
|
||||
|
||||
# OpenTelemetry OTLP/HTTP traces (same env names as server: OTEL_EXPORTER_OTLP_* via --dart-define)
|
||||
opentelemetry: ^0.18.11
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user