Update Android & IOS builds

This commit is contained in:
Dries Peeters
2026-01-14 06:53:06 +01:00
parent d7c0a277d5
commit 6d6e67db97
13 changed files with 185 additions and 23 deletions
+4
View File
@@ -70,6 +70,10 @@ jobs:
working-directory: mobile
run: flutter pub get
- name: Generate iOS platform files
working-directory: mobile
run: flutter create --platforms=ios .
# Skip tests for now - mobile app is incomplete (tests exist but lib/ source code is missing)
# - name: Run tests
# working-directory: mobile
+4
View File
@@ -972,6 +972,10 @@ jobs:
working-directory: mobile
run: flutter pub get
- name: Generate iOS platform files
working-directory: mobile
run: flutter create --platforms=ios .
- name: Build iOS (no codesign)
working-directory: mobile
run: flutter build ios --release --no-codesign
+39 -1
View File
@@ -5,6 +5,9 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class AppConfig {
static const String serverUrlKey = 'server_url';
static const String apiTokenKey = 'api_token';
static const String syncIntervalKey = 'sync_interval';
static const String autoSyncKey = 'auto_sync';
static const String themeModeKey = 'theme_mode';
static const _storage = FlutterSecureStorage();
/// Get server URL from storage (synchronous getter for splash screen)
@@ -14,6 +17,27 @@ class AppConfig {
return null;
}
/// Get sync interval (synchronous getter)
static int get syncInterval {
// This is a synchronous getter, but we can't access SharedPreferences synchronously
// For now, return default value
return 60;
}
/// Get auto sync setting (synchronous getter)
static bool get autoSync {
// This is a synchronous getter, but we can't access SharedPreferences synchronously
// For now, return default value
return true;
}
/// Get theme mode (synchronous getter)
static String get themeMode {
// This is a synchronous getter, but we can't access SharedPreferences synchronously
// For now, return default value
return 'system';
}
/// Check if token exists (synchronous getter for splash screen)
static bool get hasToken {
// This is a synchronous getter, but we can't access secure storage synchronously
@@ -39,10 +63,24 @@ class AppConfig {
await prefs.remove(serverUrlKey);
}
/// Set auto sync setting
static Future<void> setAutoSync(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(autoSyncKey, value);
}
/// Clear all stored configuration
static Future<void> clearAll() async {
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(serverUrlKey);
await prefs.remove(syncIntervalKey);
await prefs.remove(autoSyncKey);
await prefs.remove(themeModeKey);
await _storage.delete(key: apiTokenKey);
}
/// Clear all stored configuration (alias for clear)
static Future<void> clearAll() async {
await clear();
}
}
+30
View File
@@ -2,6 +2,7 @@ class TimeEntry {
final int id;
final int userId;
final int? projectId;
final int? taskId;
final DateTime? startTime;
final DateTime? endTime;
final int? durationSeconds;
@@ -9,6 +10,7 @@ class TimeEntry {
final bool billable;
final bool paid;
final String? notes;
final String? tags;
final DateTime createdAt;
final DateTime updatedAt;
@@ -16,6 +18,7 @@ class TimeEntry {
required this.id,
required this.userId,
this.projectId,
this.taskId,
this.startTime,
this.endTime,
this.durationSeconds,
@@ -23,6 +26,7 @@ class TimeEntry {
required this.billable,
required this.paid,
this.notes,
this.tags,
required this.createdAt,
required this.updatedAt,
});
@@ -32,6 +36,7 @@ class TimeEntry {
id: json['id'] as int,
userId: json['user_id'] as int,
projectId: json['project_id'] as int?,
taskId: json['task_id'] as int?,
startTime: json['start_time'] != null
? DateTime.parse(json['start_time'] as String)
: null,
@@ -43,6 +48,7 @@ class TimeEntry {
billable: json['billable'] as bool,
paid: json['paid'] as bool,
notes: json['notes'] as String?,
tags: json['tags'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
@@ -61,11 +67,34 @@ class TimeEntry {
}
}
String get formattedDateRange {
if (startTime == null && endTime == null) {
return 'No date';
}
if (startTime != null && endTime != null) {
// Format both dates
final start = '${startTime!.year}-${startTime!.month.toString().padLeft(2, '0')}-${startTime!.day.toString().padLeft(2, '0')}';
final end = '${endTime!.year}-${endTime!.month.toString().padLeft(2, '0')}-${endTime!.day.toString().padLeft(2, '0')}';
if (start == end) {
return start;
}
return '$start - $end';
}
if (startTime != null) {
return '${startTime!.year}-${startTime!.month.toString().padLeft(2, '0')}-${startTime!.day.toString().padLeft(2, '0')}';
}
if (endTime != null) {
return '${endTime!.year}-${endTime!.month.toString().padLeft(2, '0')}-${endTime!.day.toString().padLeft(2, '0')}';
}
return 'No date';
}
Map<String, dynamic> toJson() {
return {
'id': id,
'user_id': userId,
'project_id': projectId,
'task_id': taskId,
'start_time': startTime?.toIso8601String(),
'end_time': endTime?.toIso8601String(),
'duration_seconds': durationSeconds,
@@ -73,6 +102,7 @@ class TimeEntry {
'billable': billable,
'paid': paid,
'notes': notes,
'tags': tags,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
+9
View File
@@ -40,4 +40,13 @@ class Timer {
'template_id': templateId,
};
}
/// Get formatted elapsed time as HH:MM:SS
String get formattedElapsed {
final elapsed = DateTime.now().difference(startTime);
final hours = elapsed.inHours;
final minutes = elapsed.inMinutes.remainder(60);
final seconds = elapsed.inSeconds.remainder(60);
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
@@ -101,8 +101,11 @@ class TimeTrackingRepository {
/// Stop the active timer
Future<TimeEntry> stopTimer() async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final response = await apiClient.stopTimer();
final response = await apiClient!.stopTimer();
return TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to stop timer: $e');
@@ -175,8 +178,11 @@ class TimeTrackingRepository {
/// Get a specific time entry
Future<TimeEntry> getTimeEntry(int entryId) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final response = await apiClient.getTimeEntry(entryId);
final response = await apiClient!.getTimeEntry(entryId);
return TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to get time entry: $e');
@@ -258,8 +264,11 @@ class TimeTrackingRepository {
String? tags,
bool? billable,
}) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final response = await apiClient.updateTimeEntry(
final response = await apiClient!.updateTimeEntry(
entryId,
projectId: projectId,
taskId: taskId,
@@ -312,8 +321,11 @@ class TimeTrackingRepository {
int? page,
int? perPage,
}) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final response = await apiClient.getProjects(
final response = await apiClient!.getProjects(
status: status,
clientId: clientId,
page: page,
@@ -330,8 +342,11 @@ class TimeTrackingRepository {
/// Get a specific project
Future<Project> getProject(int projectId) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final response = await apiClient.getProject(projectId);
final response = await apiClient!.getProject(projectId);
return Project.fromJson(response['project'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to get project: $e');
@@ -365,8 +380,11 @@ class TimeTrackingRepository {
/// Get a specific task
Future<Task> getTask(int taskId) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final response = await apiClient.getTask(taskId);
final response = await apiClient!.getTask(taskId);
return Task.fromJson(response['task'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to get task: $e');
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/data/models/project.dart';
import 'package:timetracker_mobile/domain/repositories/time_tracking_repository.dart';
import 'package:timetracker_mobile/presentation/providers/api_provider.dart';
import 'package:timetracker_mobile/presentation/providers/timer_provider.dart';
/// Projects state
class ProjectsState {
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/data/models/task.dart';
import 'package:timetracker_mobile/domain/repositories/time_tracking_repository.dart';
import 'package:timetracker_mobile/presentation/providers/api_provider.dart';
import 'package:timetracker_mobile/presentation/providers/timer_provider.dart';
/// Tasks state
class TasksState {
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/data/models/time_entry.dart';
import 'package:timetracker_mobile/domain/repositories/time_tracking_repository.dart';
import 'package:timetracker_mobile/presentation/providers/api_provider.dart';
import 'package:timetracker_mobile/presentation/providers/timer_provider.dart';
/// Time entries filter state
class TimeEntriesFilter {
@@ -138,7 +138,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
if (value == null || value.isEmpty) {
return 'Please enter server URL';
}
if (!Uri.tryParse(value)?.hasAbsolutePath ?? true) {
final uri = Uri.tryParse(value);
if (uri == null || !uri.hasAbsolutePath) {
return 'Please enter a valid URL';
}
return null;
@@ -195,7 +195,7 @@ class _TimeEntriesScreenState extends ConsumerState<TimeEntriesScreen> {
title: const Text('Start Date'),
subtitle: Text(
startDate != null
? DateFormat('yyyy-MM-dd').format(startDate)
? DateFormat('yyyy-MM-dd').format(startDate!)
: 'No date selected',
),
trailing: const Icon(Icons.calendar_today),
@@ -217,7 +217,7 @@ class _TimeEntriesScreenState extends ConsumerState<TimeEntriesScreen> {
title: const Text('End Date'),
subtitle: Text(
endDate != null
? DateFormat('yyyy-MM-dd').format(endDate)
? DateFormat('yyyy-MM-dd').format(endDate!)
: 'No date selected',
),
trailing: const Icon(Icons.calendar_today),
@@ -255,10 +255,10 @@ class _TimeEntriesScreenState extends ConsumerState<TimeEntriesScreen> {
ref.read(timeEntriesProvider.notifier).setFilter(
currentFilter.copyWith(
startDate: startDate != null
? DateFormat('yyyy-MM-dd').format(startDate)
? DateFormat('yyyy-MM-dd').format(startDate!)
: null,
endDate: endDate != null
? DateFormat('yyyy-MM-dd').format(endDate)
? DateFormat('yyyy-MM-dd').format(endDate!)
: null,
),
);
@@ -1,7 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/presentation/providers/projects_provider.dart';
import 'package:timetracker_mobile/presentation/providers/tasks_provider.dart';
class TimeEntryCard extends StatelessWidget {
class TimeEntryCard extends ConsumerWidget {
final TimeEntry entry;
final VoidCallback? onTap;
final VoidCallback? onEdit;
@@ -16,7 +21,32 @@ class TimeEntryCard extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// Find project and task by ID
final projectsState = ref.watch(projectsProvider);
final tasksState = ref.watch(tasksProvider);
Project? project;
Task? task;
if (entry.projectId != null) {
try {
project = projectsState.projects.firstWhere(
(p) => p.id == entry.projectId,
);
} catch (e) {
project = null;
}
}
if (entry.taskId != null) {
try {
task = tasksState.tasks.firstWhere(
(t) => t.id == entry.taskId,
);
} catch (e) {
task = null;
}
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: InkWell(
@@ -33,13 +63,13 @@ class TimeEntryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.project?.name ?? 'Unknown Project',
project?.name ?? 'Unknown Project',
style: Theme.of(context).textTheme.titleMedium,
),
if (entry.task != null) ...[
if (task != null) ...[
const SizedBox(height: 4),
Text(
entry.task!.name,
task.name,
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/data/models/project.dart';
import 'package:timetracker_mobile/data/models/task.dart';
import 'package:timetracker_mobile/presentation/providers/timer_provider.dart';
import 'package:timetracker_mobile/presentation/providers/projects_provider.dart';
import 'package:timetracker_mobile/presentation/providers/tasks_provider.dart';
class TimerWidget extends ConsumerStatefulWidget {
const TimerWidget({super.key});
@@ -34,6 +38,30 @@ class _TimerWidgetState extends ConsumerState<TimerWidget> {
@override
Widget build(BuildContext context) {
final timerState = ref.watch(timerProvider);
final projectsState = ref.watch(projectsProvider);
final tasksState = ref.watch(tasksProvider);
// Find project and task by ID
Project? project;
Task? task;
if (timerState.timer != null) {
try {
project = projectsState.projects.firstWhere(
(p) => p.id == timerState.timer!.projectId,
);
} catch (e) {
project = null;
}
if (timerState.timer!.taskId != null) {
try {
task = tasksState.tasks.firstWhere(
(t) => t.id == timerState.timer!.taskId,
);
} catch (e) {
task = null;
}
}
}
return Card(
child: Padding(
@@ -57,13 +85,13 @@ class _TimerWidgetState extends ConsumerState<TimerWidget> {
),
const SizedBox(height: 8),
Text(
timerState.timer!.project?.name ?? 'Unknown Project',
project?.name ?? 'Unknown Project',
style: Theme.of(context).textTheme.titleMedium,
),
if (timerState.timer!.task != null) ...[
if (task != null) ...[
const SizedBox(height: 4),
Text(
timerState.timer!.task!.name,
task.name,
style: Theme.of(context).textTheme.bodyMedium,
),
],