mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 21:00:15 -05:00
Update Android & IOS builds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user