fix(mobile): add missing Flutter app source files to fix build errors

- Add lib/main.dart entry point with proper routing setup
- Create Timer model (lib/data/models/timer.dart) with toJson support
- Enhance ApiClient with all required API methods:
  * Timer operations (getTimerStatus, startTimer, stopTimer)
  * Time entry operations (CRUD)
  * Project and task operations
- Add toJson() methods to TimeEntry and Timer models
- Fix splash screen auth check to use async methods properly
- Add missing provider methods (getElapsedTime, checkTimerStatus, loadTimeEntries)
- Update .gitignore to allow tracking mobile/lib/ directory

Fixes build error: 'Target file lib/main.dart not found' when running
flutter build apk --release or flutter build ios --release
This commit is contained in:
Dries Peeters
2026-01-13 13:43:56 +01:00
parent ba9b789c51
commit e9049ef923
35 changed files with 4311 additions and 0 deletions
+1
View File
@@ -15,6 +15,7 @@ downloads/
eggs/
.eggs/
lib/
!mobile/lib/
lib64/
parts/
sdist/
+71
View File
@@ -0,0 +1,71 @@
/// Core configuration for TimeTracker Mobile App
///
/// This file handles server URL configuration and other app-wide settings.
/// The server URL can be:
/// 1. Set via environment variable: TIMETRACKER_SERVER_URL
/// 2. Configured in the app settings
/// 3. Defaults to empty string (must be configured by user)
import 'package:shared_preferences/shared_preferences.dart';
class AppConfig {
static const String _serverUrlKey = 'server_url';
static const String _apiTokenKey = 'api_token';
/// Get server URL from preferences or environment
static Future<String?> getServerUrl() async {
// First check environment variable
const envUrl = String.fromEnvironment('TIMETRACKER_SERVER_URL');
if (envUrl.isNotEmpty) {
return envUrl;
}
// Then check shared preferences
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_serverUrlKey);
}
/// Set server URL in preferences
static Future<bool> setServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
return await prefs.setString(_serverUrlKey, url);
}
/// Get API token from preferences
static Future<String?> getApiToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_apiTokenKey);
}
/// Set API token in preferences
static Future<bool> setApiToken(String token) async {
final prefs = await SharedPreferences.getInstance();
return await prefs.setString(_apiTokenKey, token);
}
/// Clear all stored configuration
static Future<bool> clearConfig() async {
final prefs = await SharedPreferences.getInstance();
return await prefs.remove(_serverUrlKey) &&
await prefs.remove(_apiTokenKey);
}
/// Validate server URL format
static bool isValidServerUrl(String url) {
try {
final uri = Uri.parse(url);
return uri.hasScheme &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority;
} catch (e) {
return false;
}
}
/// Get default server URL (can be overridden)
static String? getDefaultServerUrl() {
// Can be set at compile time via --dart-define
const defaultUrl = String.fromEnvironment('DEFAULT_SERVER_URL');
return defaultUrl.isEmpty ? null : defaultUrl;
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// App configuration and settings
class AppConfig {
static const String serverUrlKey = 'server_url';
static const String apiTokenKey = 'api_token';
static const _storage = FlutterSecureStorage();
/// Get server URL from storage (synchronous getter for splash screen)
static String? get serverUrl {
// This is a synchronous getter, but we can't access SharedPreferences synchronously
// For now, return null and let the splash screen handle async loading
return null;
}
/// 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
// For now, return false and let the splash screen handle async loading
return false;
}
/// Get server URL from storage
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(serverUrlKey);
}
/// Save server URL to storage
static Future<void> setServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(serverUrlKey, url);
}
/// Clear server URL from storage
static Future<void> clearServerUrl() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(serverUrlKey);
}
/// Clear all stored configuration
static Future<void> clearAll() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(serverUrlKey);
await _storage.delete(key: apiTokenKey);
}
}
@@ -0,0 +1,34 @@
class AppConstants {
// API Configuration
static const String apiVersion = 'v1';
static const String defaultSyncInterval = '60'; // seconds
static const int timerPollInterval = 5; // seconds
// Storage Keys
static const String boxTimeEntries = 'time_entries';
static const String boxProjects = 'projects';
static const String boxTasks = 'tasks';
static const String boxSyncQueue = 'sync_queue';
static const String boxFavorites = 'favorites';
// Routes
static const String routeSplash = '/';
static const String routeLogin = '/login';
static const String routeHome = '/home';
static const String routeTimer = '/timer';
static const String routeProjects = '/projects';
static const String routeTasks = '/tasks';
static const String routeTimeEntries = '/time-entries';
static const String routeSettings = '/settings';
// Notification IDs
static const int notificationTimerRunning = 1;
static const int notificationSyncStatus = 2;
static const int notificationIdleReminder = 3;
// Time Formats
static const String timeFormat24h = 'HH:mm:ss';
static const String timeFormat12h = 'hh:mm:ss a';
static const String dateFormat = 'yyyy-MM-dd';
static const String dateTimeFormat = 'yyyy-MM-dd HH:mm:ss';
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2196F3),
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2196F3),
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
class AppTheme {
// Color Scheme
static const Color primaryColor = Color(0xFF2196F3);
static const Color secondaryColor = Color(0xFF03A9F4);
static const Color errorColor = Color(0xFFE53935);
static const Color successColor = Color(0xFF4CAF50);
static const Color warningColor = Color(0xFFFF9800);
// Light Theme
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
// Dark Theme
static ThemeData darkTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
}
+192
View File
@@ -0,0 +1,192 @@
import 'package:dio/dio.dart';
class ApiClient {
final String baseUrl;
late final Dio _dio;
String? _authToken;
ApiClient({required this.baseUrl}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
headers: {
'Content-Type': 'application/json',
},
));
}
/// Set authentication token
Future<void> setAuthToken(String token) async {
_authToken = token;
_dio.options.headers['Authorization'] = 'Bearer $token';
}
/// Validate token by making a test API call
Future<bool> validateToken() async {
try {
final response = await _dio.get('/api/v1/timer/status');
return response.statusCode == 200;
} catch (e) {
return false;
}
}
// ==================== Timer Operations ====================
/// Get timer status
Future<Map<String, dynamic>> getTimerStatus() async {
final response = await _dio.get('/api/v1/timer/status');
return response.data as Map<String, dynamic>;
}
/// Start timer
Future<Map<String, dynamic>> startTimer({
required int projectId,
int? taskId,
String? notes,
int? templateId,
}) async {
final response = await _dio.post('/api/v1/timer/start', data: {
'project_id': projectId,
if (taskId != null) 'task_id': taskId,
if (notes != null) 'notes': notes,
if (templateId != null) 'template_id': templateId,
});
return response.data as Map<String, dynamic>;
}
/// Stop timer
Future<Map<String, dynamic>> stopTimer() async {
final response = await _dio.post('/api/v1/timer/stop');
return response.data as Map<String, dynamic>;
}
// ==================== Time Entry Operations ====================
/// Get time entries
Future<Map<String, dynamic>> getTimeEntries({
int? projectId,
String? startDate,
String? endDate,
bool? billable,
int? page,
int? perPage,
}) async {
final queryParams = <String, dynamic>{};
if (projectId != null) queryParams['project_id'] = projectId;
if (startDate != null) queryParams['start_date'] = startDate;
if (endDate != null) queryParams['end_date'] = endDate;
if (billable != null) queryParams['billable'] = billable;
if (page != null) queryParams['page'] = page;
if (perPage != null) queryParams['per_page'] = perPage;
final response = await _dio.get('/api/v1/time-entries', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
/// Get a specific time entry
Future<Map<String, dynamic>> getTimeEntry(int entryId) async {
final response = await _dio.get('/api/v1/time-entries/$entryId');
return response.data as Map<String, dynamic>;
}
/// Create time entry
Future<Map<String, dynamic>> createTimeEntry({
required int projectId,
int? taskId,
required String startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
final response = await _dio.post('/api/v1/time-entries', data: {
'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,
});
return response.data as Map<String, dynamic>;
}
/// Update time entry
Future<Map<String, dynamic>> updateTimeEntry(
int entryId, {
int? projectId,
int? taskId,
String? startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
final data = <String, dynamic>{};
if (projectId != null) data['project_id'] = projectId;
if (taskId != null) data['task_id'] = taskId;
if (startTime != null) data['start_time'] = startTime;
if (endTime != null) data['end_time'] = endTime;
if (notes != null) data['notes'] = notes;
if (tags != null) data['tags'] = tags;
if (billable != null) data['billable'] = billable;
final response = await _dio.put('/api/v1/time-entries/$entryId', data: data);
return response.data as Map<String, dynamic>;
}
/// Delete time entry
Future<void> deleteTimeEntry(int entryId) async {
await _dio.delete('/api/v1/time-entries/$entryId');
}
// ==================== Project Operations ====================
/// Get projects
Future<Map<String, dynamic>> getProjects({
String? status,
int? clientId,
int? page,
int? perPage,
}) async {
final queryParams = <String, dynamic>{};
if (status != null) queryParams['status'] = status;
if (clientId != null) queryParams['client_id'] = clientId;
if (page != null) queryParams['page'] = page;
if (perPage != null) queryParams['per_page'] = perPage;
final response = await _dio.get('/api/v1/projects', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
/// Get a specific project
Future<Map<String, dynamic>> getProject(int projectId) async {
final response = await _dio.get('/api/v1/projects/$projectId');
return response.data as Map<String, dynamic>;
}
// ==================== Task Operations ====================
/// Get tasks
Future<Map<String, dynamic>> getTasks({
int? projectId,
String? status,
int? page,
int? perPage,
}) async {
final queryParams = <String, dynamic>{};
if (projectId != null) queryParams['project_id'] = projectId;
if (status != null) queryParams['status'] = status;
if (page != null) queryParams['page'] = page;
if (perPage != null) queryParams['per_page'] = perPage;
final response = await _dio.get('/api/v1/tasks', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
/// Get a specific task
Future<Map<String, dynamic>> getTask(int taskId) async {
final response = await _dio.get('/api/v1/tasks/$taskId');
return response.data as Map<String, dynamic>;
}
}
@@ -0,0 +1,105 @@
import 'package:workmanager/workmanager.dart';
import '../../api/api_client.dart';
import '../../models/time_entry.dart';
import '../../../core/config/app_config.dart';
import '../../../utils/auth/auth_service.dart';
import 'dart:convert';
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
try {
switch (task) {
case 'timerStatusUpdate':
return await _updateTimerStatus();
case 'syncData':
return await _syncData();
default:
return Future.value(false);
}
} catch (e) {
return Future.value(false);
}
});
}
Future<bool> _updateTimerStatus() async {
try {
final serverUrl = AppConfig.serverUrl;
final token = await AuthService.getToken();
if (serverUrl == null || token == null) {
return false;
}
final apiClient = ApiClient(baseUrl: serverUrl);
await apiClient.setAuthToken(token);
final response = await apiClient.getTimerStatus();
if (response.statusCode == 200 && response.data['active'] == true) {
// Timer is still running, could update local notification
return true;
}
return false;
} catch (e) {
return false;
}
}
Future<bool> _syncData() async {
try {
final serverUrl = AppConfig.serverUrl;
final token = await AuthService.getToken();
if (serverUrl == null || token == null) {
return false;
}
final apiClient = ApiClient(baseUrl: serverUrl);
await apiClient.setAuthToken(token);
// Sync time entries
final now = DateTime.now();
final startDate = now.subtract(const Duration(days: 7));
await apiClient.getTimeEntries(
startDate: startDate.toIso8601String().split('T')[0],
endDate: now.toIso8601String().split('T')[0],
);
return true;
} catch (e) {
return false;
}
}
class WorkManagerService {
static Future<void> initialize() async {
await Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
}
static Future<void> startTimerStatusUpdates() async {
await Workmanager().registerPeriodicTask(
'timerStatusUpdate',
'timerStatusUpdate',
frequency: const Duration(minutes: 5),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
}
static Future<void> startDataSync() async {
await Workmanager().registerPeriodicTask(
'syncData',
'syncData',
frequency: const Duration(minutes: 15),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
}
static Future<void> cancelAll() async {
await Workmanager().cancelAll();
}
}
@@ -0,0 +1,61 @@
import 'package:hive_flutter/hive_flutter.dart';
import '../../../core/constants/app_constants.dart';
class HiveService {
static Future<void> init() async {
await Hive.initFlutter();
// Note: Using JSON storage instead of type adapters for simplicity
// Models are serialized to/from JSON when storing in Hive
// To use type adapters instead, add @HiveType() annotations to models
// and run: flutter pub run build_runner build
}
// Time Entries Box
static Box get timeEntriesBox => Hive.box(AppConstants.boxTimeEntries);
static Future<Box> openTimeEntriesBox() async {
return await Hive.openBox(AppConstants.boxTimeEntries);
}
// Projects Box
static Box get projectsBox => Hive.box(AppConstants.boxProjects);
static Future<Box> openProjectsBox() async {
return await Hive.openBox(AppConstants.boxProjects);
}
// Tasks Box
static Box get tasksBox => Hive.box(AppConstants.boxTasks);
static Future<Box> openTasksBox() async {
return await Hive.openBox(AppConstants.boxTasks);
}
// Sync Queue Box
static Box get syncQueueBox => Hive.box(AppConstants.boxSyncQueue);
static Future<Box> openSyncQueueBox() async {
return await Hive.openBox(AppConstants.boxSyncQueue);
}
// Favorites Box
static Box get favoritesBox => Hive.box(AppConstants.boxFavorites);
static Future<Box> openFavoritesBox() async {
return await Hive.openBox(AppConstants.boxFavorites);
}
// Initialize all boxes
static Future<void> initBoxes() async {
await openTimeEntriesBox();
await openProjectsBox();
await openTasksBox();
await openSyncQueueBox();
await openFavoritesBox();
}
// Clear all data (logout)
static Future<void> clearAll() async {
await timeEntriesBox.clear();
await projectsBox.clear();
await tasksBox.clear();
await syncQueueBox.clear();
await favoritesBox.clear();
}
}
@@ -0,0 +1,210 @@
import '../../../core/constants/app_constants.dart';
import '../database/hive_service.dart';
import '../../api/api_client.dart';
import '../../models/time_entry.dart';
import '../../models/project.dart';
import '../../models/task.dart';
class SyncQueueItem {
final String id;
final String type; // 'time_entry', 'project', 'task'
final String action; // 'create', 'update', 'delete'
final Map<String, dynamic> data;
final DateTime timestamp;
SyncQueueItem({
required this.id,
required this.type,
required this.action,
required this.data,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'action': action,
'data': data,
'timestamp': timestamp.toIso8601String(),
};
}
factory SyncQueueItem.fromJson(Map<String, dynamic> json) {
return SyncQueueItem(
id: json['id'],
type: json['type'],
action: json['action'],
data: json['data'],
timestamp: DateTime.parse(json['timestamp']),
);
}
}
class SyncService {
final ApiClient apiClient;
SyncService(this.apiClient);
// Add item to sync queue
Future<void> addToQueue({
required String type,
required String action,
required Map<String, dynamic> data,
}) async {
final item = SyncQueueItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
type: type,
action: action,
data: data,
timestamp: DateTime.now(),
);
await HiveService.syncQueueBox.put(item.id, item.toJson());
}
// Process sync queue
Future<void> processQueue() async {
final queueBox = HiveService.syncQueueBox;
final queueItems = queueBox.values.toList();
for (final itemData in queueItems) {
final item = SyncQueueItem.fromJson(Map<String, dynamic>.from(itemData));
try {
await _processSyncItem(item);
await queueBox.delete(item.id);
} catch (e) {
// Log error but continue with other items
print('Error syncing item ${item.id}: $e');
}
}
}
Future<void> _processSyncItem(SyncQueueItem item) async {
switch (item.type) {
case 'time_entry':
await _syncTimeEntry(item);
break;
case 'project':
await _syncProject(item);
break;
case 'task':
await _syncTask(item);
break;
}
}
Future<void> _syncTimeEntry(SyncQueueItem item) async {
switch (item.action) {
case 'create':
await apiClient.createTimeEntry(item.data);
break;
case 'update':
await apiClient.updateTimeEntry(item.data['id'], item.data);
break;
case 'delete':
await apiClient.deleteTimeEntry(item.data['id']);
break;
}
}
Future<void> _syncProject(SyncQueueItem item) async {
// Similar implementation for projects
// This is a placeholder as project sync may not be needed
}
Future<void> _syncTask(SyncQueueItem item) async {
// Similar implementation for tasks
// This is a placeholder as task sync may not be needed
}
// Sync local data with server
Future<void> syncFromServer() async {
try {
// Sync projects
final projectsResponse = await apiClient.getProjects(status: 'active');
if (projectsResponse.statusCode == 200) {
final projects = (projectsResponse.data['projects'] as List)
.map((json) => Project.fromJson(json))
.toList();
for (final project in projects) {
await HiveService.projectsBox.put(project.id, project.toJson());
}
}
// Sync time entries (recent ones)
final now = DateTime.now();
final startDate = now.subtract(const Duration(days: 30));
final entriesResponse = await apiClient.getTimeEntries(
startDate: startDate.toIso8601String().split('T')[0],
endDate: now.toIso8601String().split('T')[0],
);
if (entriesResponse.statusCode == 200) {
final entries = (entriesResponse.data['time_entries'] as List)
.map((json) => TimeEntry.fromJson(json))
.toList();
for (final entry in entries) {
await HiveService.timeEntriesBox.put(entry.id, entry.toJson());
}
}
} catch (e) {
print('Error syncing from server: $e');
rethrow;
}
}
// Get cached data (stored as JSON in Hive)
List<Project> getCachedProjects() {
try {
return HiveService.projectsBox.values
.map((value) {
// Handle both Map and JSON string
if (value is Map) {
return Project.fromJson(Map<String, dynamic>.from(value));
} else if (value is String) {
// If stored as string, parse it (though Hive usually stores as Map)
return Project.fromJson(Map<String, dynamic>.from(value));
}
throw Exception('Invalid project data format');
})
.toList();
} catch (e) {
return [];
}
}
List<TimeEntry> getCachedTimeEntries({
DateTime? startDate,
DateTime? endDate,
int? projectId,
}) {
try {
var entries = HiveService.timeEntriesBox.values
.map((value) {
// Handle both Map and JSON string
if (value is Map) {
return TimeEntry.fromJson(Map<String, dynamic>.from(value));
} else if (value is String) {
return TimeEntry.fromJson(Map<String, dynamic>.from(value));
}
throw Exception('Invalid time entry data format');
})
.toList();
if (startDate != null) {
entries = entries.where((e) => e.startTime.isAfter(startDate)).toList();
}
if (endDate != null) {
entries = entries.where((e) => e.startTime.isBefore(endDate)).toList();
}
if (projectId != null) {
entries = entries.where((e) => e.projectId == projectId).toList();
}
return entries;
}
}
+31
View File
@@ -0,0 +1,31 @@
class Project {
final int id;
final String name;
final String? client;
final String status;
final bool billable;
final DateTime createdAt;
final DateTime updatedAt;
Project({
required this.id,
required this.name,
this.client,
required this.status,
required this.billable,
required this.createdAt,
required this.updatedAt,
});
factory Project.fromJson(Map<String, dynamic> json) {
return Project(
id: json['id'] as int,
name: json['name'] as String,
client: json['client'] as String?,
status: json['status'] as String,
billable: json['billable'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
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;
Task({
required this.id,
required this.projectId,
required this.name,
required this.status,
this.priority,
required this.createdBy,
required this.createdAt,
required this.updatedAt,
});
factory Task.fromJson(Map<String, dynamic> json) {
return Task(
id: json['id'] as int,
projectId: json['project_id'] as int,
name: json['name'] as String,
status: json['status'] as String,
priority: json['priority'] as String?,
createdBy: json['created_by'] as int,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
}
+80
View File
@@ -0,0 +1,80 @@
class TimeEntry {
final int id;
final int userId;
final int? projectId;
final DateTime? startTime;
final DateTime? endTime;
final int? durationSeconds;
final String source;
final bool billable;
final bool paid;
final String? notes;
final DateTime createdAt;
final DateTime updatedAt;
TimeEntry({
required this.id,
required this.userId,
this.projectId,
this.startTime,
this.endTime,
this.durationSeconds,
required this.source,
required this.billable,
required this.paid,
this.notes,
required this.createdAt,
required this.updatedAt,
});
factory TimeEntry.fromJson(Map<String, dynamic> json) {
return TimeEntry(
id: json['id'] as int,
userId: json['user_id'] as int,
projectId: json['project_id'] as int?,
startTime: json['start_time'] != null
? DateTime.parse(json['start_time'] as String)
: null,
endTime: json['end_time'] != null
? DateTime.parse(json['end_time'] as String)
: null,
durationSeconds: json['duration_seconds'] as int?,
source: json['source'] as String,
billable: json['billable'] as bool,
paid: json['paid'] as bool,
notes: json['notes'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
String get formattedDuration {
if (durationSeconds == null) return '0m';
final hours = durationSeconds! ~/ 3600;
final minutes = (durationSeconds! % 3600) ~/ 60;
if (hours > 0 && minutes > 0) {
return '${hours}h ${minutes}m';
} else if (hours > 0) {
return '${hours}h';
} else {
return '${minutes}m';
}
}
Map<String, dynamic> toJson() {
return {
'id': id,
'user_id': userId,
'project_id': projectId,
'start_time': startTime?.toIso8601String(),
'end_time': endTime?.toIso8601String(),
'duration_seconds': durationSeconds,
'source': source,
'billable': billable,
'paid': paid,
'notes': notes,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
}
+43
View File
@@ -0,0 +1,43 @@
class Timer {
final int id;
final int userId;
final int projectId;
final int? taskId;
final DateTime startTime;
final String? notes;
final int? templateId;
Timer({
required this.id,
required this.userId,
required this.projectId,
this.taskId,
required this.startTime,
this.notes,
this.templateId,
});
factory Timer.fromJson(Map<String, dynamic> json) {
return Timer(
id: json['id'] as int,
userId: json['user_id'] as int,
projectId: json['project_id'] as int,
taskId: json['task_id'] as int?,
startTime: DateTime.parse(json['start_time'] as String),
notes: json['notes'] as String?,
templateId: json['template_id'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'user_id': userId,
'project_id': projectId,
'task_id': taskId,
'start_time': startTime.toIso8601String(),
'notes': notes,
'template_id': templateId,
};
}
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:timetracker_mobile/data/models/time_entry.dart';
import 'package:timetracker_mobile/data/models/timer.dart';
/// Local storage using Hive for offline support
class LocalStorage {
static const String _timeEntriesBox = 'time_entries';
static const String _timerBox = 'timer';
static const String _syncQueueBox = 'sync_queue';
static Future<void> init() async {
await Hive.initFlutter();
// Register adapters if needed (for now we'll use JSON strings)
// In production, you'd want to create proper Hive adapters
}
// ==================== Time Entries ====================
/// Save time entry locally
static Future<void> saveTimeEntry(TimeEntry entry) async {
final box = await Hive.openBox(_timeEntriesBox);
await box.put(entry.id.toString(), entry.toJson());
}
/// Get time entry from local storage
static Future<TimeEntry?> getTimeEntry(int entryId) async {
final box = await Hive.openBox(_timeEntriesBox);
final data = box.get(entryId.toString());
if (data != null) {
return TimeEntry.fromJson(Map<String, dynamic>.from(data));
}
return null;
}
/// Get all time entries from local storage
static Future<List<TimeEntry>> getAllTimeEntries() async {
final box = await Hive.openBox(_timeEntriesBox);
final entries = <TimeEntry>[];
for (var key in box.keys) {
final data = box.get(key);
if (data != null) {
try {
entries.add(TimeEntry.fromJson(Map<String, dynamic>.from(data)));
} catch (e) {
// Skip invalid entries
}
}
}
return entries;
}
/// Delete time entry from local storage
static Future<void> deleteTimeEntry(int entryId) async {
final box = await Hive.openBox(_timeEntriesBox);
await box.delete(entryId.toString());
}
/// Clear all time entries
static Future<void> clearTimeEntries() async {
final box = await Hive.openBox(_timeEntriesBox);
await box.clear();
}
// ==================== Timer ====================
/// Save timer locally
static Future<void> saveTimer(Timer timer) async {
final box = await Hive.openBox(_timerBox);
await box.put('active', timer.toJson());
}
/// Get timer from local storage
static Future<Timer?> getTimer() async {
final box = await Hive.openBox(_timerBox);
final data = box.get('active');
if (data != null) {
return Timer.fromJson(Map<String, dynamic>.from(data));
}
return null;
}
/// Clear timer from local storage
static Future<void> clearTimer() async {
final box = await Hive.openBox(_timerBox);
await box.delete('active');
}
// ==================== Sync Queue ====================
/// Add operation to sync queue
static Future<void> addToSyncQueue({
required String operation,
required Map<String, dynamic> data,
}) async {
final box = await Hive.openBox(_syncQueueBox);
final id = DateTime.now().millisecondsSinceEpoch.toString();
await box.put(id, {
'id': id,
'operation': operation,
'data': data,
'created_at': DateTime.now().toIso8601String(),
'retry_count': 0,
});
}
/// Get all pending sync operations
static Future<List<Map<String, dynamic>>> getSyncQueue() async {
final box = await Hive.openBox(_syncQueueBox);
final operations = <Map<String, dynamic>>[];
for (var key in box.keys) {
final data = box.get(key);
if (data != null) {
operations.add(Map<String, dynamic>.from(data));
}
}
// Sort by creation time
operations.sort((a, b) {
final aTime = DateTime.parse(a['created_at'] as String);
final bTime = DateTime.parse(b['created_at'] as String);
return aTime.compareTo(bTime);
});
return operations;
}
/// Remove operation from sync queue
static Future<void> removeFromSyncQueue(String operationId) async {
final box = await Hive.openBox(_syncQueueBox);
await box.delete(operationId);
}
/// Update retry count for sync operation
static Future<void> updateSyncQueueRetry(String operationId, int retryCount) async {
final box = await Hive.openBox(_syncQueueBox);
final data = box.get(operationId);
if (data != null) {
final operation = Map<String, dynamic>.from(data);
operation['retry_count'] = retryCount;
await box.put(operationId, operation);
}
}
/// Clear sync queue
static Future<void> clearSyncQueue() async {
final box = await Hive.openBox(_syncQueueBox);
await box.clear();
}
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
import 'package:timetracker_mobile/data/storage/local_storage.dart';
/// Service for syncing offline data with server
class SyncService {
final ApiClient? apiClient;
final Connectivity _connectivity = Connectivity();
bool _isSyncing = false;
SyncService(this.apiClient);
/// Check if device is online
Future<bool> isOnline() async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
/// Sync all pending operations
Future<void> syncAll() async {
if (_isSyncing || apiClient == null) return;
final isOnline = await this.isOnline();
if (!isOnline) return;
_isSyncing = true;
try {
final queue = await LocalStorage.getSyncQueue();
for (final operation in queue) {
try {
// Retry logic with exponential backoff
int retryCount = operation['retry_count'] as int? ?? 0;
bool success = false;
int attempts = 0;
const maxAttempts = 3;
while (!success && attempts < maxAttempts) {
try {
await _processOperation(operation);
success = true;
await LocalStorage.removeFromSyncQueue(operation['id'] as String);
} catch (e) {
attempts++;
if (attempts < maxAttempts) {
// Exponential backoff: wait 1s, 2s, 4s
await Future.delayed(Duration(seconds: 1 << (attempts - 1)));
} else {
// Final failure - update retry count
retryCount++;
await LocalStorage.updateSyncQueueRetry(
operation['id'] as String,
retryCount,
);
// Remove if retried too many times (5 total retries across sync attempts)
if (retryCount >= 5) {
await LocalStorage.removeFromSyncQueue(operation['id'] as String);
}
rethrow;
}
}
}
} catch (e) {
// Operation failed after all retries - will be retried on next sync
print('Failed to sync operation ${operation['id']}: $e');
}
}
} finally {
_isSyncing = false;
}
}
Future<void> _processOperation(Map<String, dynamic> operation) async {
final opType = operation['operation'] as String;
final data = operation['data'] as Map<String, dynamic>;
switch (opType) {
case 'create_time_entry':
await apiClient!.createTimeEntry(
projectId: data['project_id'] as int,
taskId: data['task_id'] as int?,
startTime: data['start_time'] as String,
endTime: data['end_time'] as String?,
notes: data['notes'] as String?,
tags: data['tags'] as String?,
billable: data['billable'] as bool?,
);
break;
case 'update_time_entry':
await apiClient!.updateTimeEntry(
data['entry_id'] as int,
projectId: data['project_id'] as int?,
taskId: data['task_id'] as int?,
startTime: data['start_time'] as String?,
endTime: data['end_time'] as String?,
notes: data['notes'] as String?,
tags: data['tags'] as String?,
billable: data['billable'] as bool?,
);
break;
case 'delete_time_entry':
await apiClient!.deleteTimeEntry(data['entry_id'] as int);
break;
case 'start_timer':
await apiClient!.startTimer(
projectId: data['project_id'] as int,
taskId: data['task_id'] as int?,
notes: data['notes'] as String?,
);
break;
case 'stop_timer':
await apiClient!.stopTimer();
break;
}
}
/// Add create time entry to sync queue
static Future<void> queueCreateTimeEntry({
required int projectId,
int? taskId,
required String startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
await LocalStorage.addToSyncQueue(
operation: 'create_time_entry',
data: {
'project_id': projectId,
'task_id': taskId,
'start_time': startTime,
'end_time': endTime,
'notes': notes,
'tags': tags,
'billable': billable,
},
);
}
/// Add update time entry to sync queue
static Future<void> queueUpdateTimeEntry({
required int entryId,
int? projectId,
int? taskId,
String? startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
await LocalStorage.addToSyncQueue(
operation: 'update_time_entry',
data: {
'entry_id': entryId,
'project_id': projectId,
'task_id': taskId,
'start_time': startTime,
'end_time': endTime,
'notes': notes,
'tags': tags,
'billable': billable,
},
);
}
/// Add delete time entry to sync queue
static Future<void> queueDeleteTimeEntry(int entryId) async {
await LocalStorage.addToSyncQueue(
operation: 'delete_time_entry',
data: {'entry_id': entryId},
);
}
}
@@ -0,0 +1,375 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
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/data/storage/local_storage.dart';
import 'package:timetracker_mobile/data/storage/sync_service.dart';
/// Repository for time tracking operations
class TimeTrackingRepository {
final ApiClient? apiClient;
final Connectivity _connectivity = Connectivity();
SyncService? _syncService;
TimeTrackingRepository(this.apiClient) {
_syncService = SyncService(apiClient);
}
Future<bool> _isOnline() async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
// ==================== Timer Operations ====================
/// Get current timer status
Future<Timer?> getTimerStatus() async {
if (apiClient == null) {
// Return cached timer if offline
return await LocalStorage.getTimer();
}
try {
final isOnline = await _isOnline();
if (!isOnline) {
return await LocalStorage.getTimer();
}
final response = await apiClient!.getTimerStatus();
if (response['active'] == true && response['timer'] != null) {
final timer = Timer.fromJson(response['timer'] as Map<String, dynamic>);
await LocalStorage.saveTimer(timer);
return timer;
}
await LocalStorage.clearTimer();
return null;
} catch (e) {
// Return cached timer on error
return await LocalStorage.getTimer();
}
}
/// Start a timer
Future<Timer> startTimer({
required int projectId,
int? taskId,
String? notes,
int? templateId,
}) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final isOnline = await _isOnline();
if (!isOnline) {
// Queue for sync
await SyncService.queueCreateTimeEntry(
projectId: projectId,
taskId: taskId,
startTime: DateTime.now().toIso8601String(),
notes: notes,
);
// Create a local timer representation
final timer = Timer(
id: DateTime.now().millisecondsSinceEpoch,
userId: 0, // Will be set by server
projectId: projectId,
taskId: taskId,
startTime: DateTime.now(),
notes: notes,
);
await LocalStorage.saveTimer(timer);
return timer;
}
final response = await apiClient!.startTimer(
projectId: projectId,
taskId: taskId,
notes: notes,
templateId: templateId,
);
final timer = Timer.fromJson(response['timer'] as Map<String, dynamic>);
await LocalStorage.saveTimer(timer);
return timer;
} catch (e) {
throw Exception('Failed to start timer: $e');
}
}
/// Stop the active timer
Future<TimeEntry> stopTimer() async {
try {
final response = await apiClient.stopTimer();
return TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to stop timer: $e');
}
}
// ==================== Time Entry Operations ====================
/// Get time entries with filters
Future<List<TimeEntry>> getTimeEntries({
int? projectId,
String? startDate,
String? endDate,
bool? billable,
int? page,
int? perPage,
}) async {
if (apiClient == null) {
// Return cached entries if offline
final entries = await LocalStorage.getAllTimeEntries();
// Apply basic filtering
var filtered = entries;
if (projectId != null) {
filtered = filtered.where((e) => e.projectId == projectId).toList();
}
return filtered;
}
try {
final isOnline = await _isOnline();
if (!isOnline) {
// Return cached entries
final entries = await LocalStorage.getAllTimeEntries();
var filtered = entries;
if (projectId != null) {
filtered = filtered.where((e) => e.projectId == projectId).toList();
}
return filtered;
}
final response = await apiClient!.getTimeEntries(
projectId: projectId,
startDate: startDate,
endDate: endDate,
billable: billable,
page: page,
perPage: perPage,
);
final entries = response['time_entries'] as List<dynamic>? ?? [];
final timeEntries = entries
.map((e) => TimeEntry.fromJson(e as Map<String, dynamic>))
.toList();
// Cache entries
for (final entry in timeEntries) {
await LocalStorage.saveTimeEntry(entry);
}
return timeEntries;
} catch (e) {
// Return cached entries on error
final entries = await LocalStorage.getAllTimeEntries();
var filtered = entries;
if (projectId != null) {
filtered = filtered.where((e) => e.projectId == projectId).toList();
}
return filtered;
}
}
/// Get a specific time entry
Future<TimeEntry> getTimeEntry(int entryId) async {
try {
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');
}
}
/// Create a manual time entry
Future<TimeEntry> createTimeEntry({
required int projectId,
int? taskId,
required String startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final isOnline = await _isOnline();
if (!isOnline) {
// Queue for sync
await SyncService.queueCreateTimeEntry(
projectId: projectId,
taskId: taskId,
startTime: startTime,
endTime: endTime,
notes: notes,
tags: tags,
billable: billable,
);
// Create a local entry representation
final entry = TimeEntry(
id: DateTime.now().millisecondsSinceEpoch,
userId: 0,
projectId: projectId,
taskId: taskId,
startTime: DateTime.parse(startTime),
endTime: endTime != null ? DateTime.parse(endTime) : null,
notes: notes,
tags: tags,
billable: billable ?? true,
paid: false,
source: 'manual',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await LocalStorage.saveTimeEntry(entry);
return entry;
}
final response = await apiClient!.createTimeEntry(
projectId: projectId,
taskId: taskId,
startTime: startTime,
endTime: endTime,
notes: notes,
tags: tags,
billable: billable,
);
final entry = TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
await LocalStorage.saveTimeEntry(entry);
return entry;
} catch (e) {
throw Exception('Failed to create time entry: $e');
}
}
/// Update a time entry
Future<TimeEntry> updateTimeEntry(
int entryId, {
int? projectId,
int? taskId,
String? startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
try {
final response = await apiClient.updateTimeEntry(
entryId,
projectId: projectId,
taskId: taskId,
startTime: startTime,
endTime: endTime,
notes: notes,
tags: tags,
billable: billable,
);
return TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to update time entry: $e');
}
}
/// Delete a time entry
Future<void> deleteTimeEntry(int entryId) async {
if (apiClient == null) {
throw Exception('Not connected to server');
}
try {
final isOnline = await _isOnline();
if (!isOnline) {
// Queue for sync
await SyncService.queueDeleteTimeEntry(entryId);
// Remove from local storage
await LocalStorage.deleteTimeEntry(entryId);
return;
}
await apiClient!.deleteTimeEntry(entryId);
await LocalStorage.deleteTimeEntry(entryId);
} catch (e) {
throw Exception('Failed to delete time entry: $e');
}
}
/// Sync pending operations
Future<void> syncPending() async {
await _syncService?.syncAll();
}
// ==================== Project Operations ====================
/// Get projects
Future<List<Project>> getProjects({
String? status,
int? clientId,
int? page,
int? perPage,
}) async {
try {
final response = await apiClient.getProjects(
status: status,
clientId: clientId,
page: page,
perPage: perPage,
);
final projects = response['projects'] as List<dynamic>? ?? [];
return projects
.map((p) => Project.fromJson(p as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get projects: $e');
}
}
/// Get a specific project
Future<Project> getProject(int projectId) async {
try {
final response = await apiClient.getProject(projectId);
return Project.fromJson(response['project'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to get project: $e');
}
}
// ==================== Task Operations ====================
/// Get tasks
Future<List<Task>> getTasks({
int? projectId,
String? status,
int? page,
int? perPage,
}) async {
try {
final response = await apiClient.getTasks(
projectId: projectId,
status: status,
page: page,
perPage: perPage,
);
final tasks = response['tasks'] as List<dynamic>? ?? [];
return tasks
.map((t) => Task.fromJson(t as Map<String, dynamic>))
.toList();
} catch (e) {
throw Exception('Failed to get tasks: $e');
}
}
/// Get a specific task
Future<Task> getTask(int taskId) async {
try {
final response = await apiClient.getTask(taskId);
return Task.fromJson(response['task'] as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to get task: $e');
}
}
}
@@ -0,0 +1,76 @@
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';
class SyncUseCase {
final SyncService _syncService;
final Connectivity _connectivity = Connectivity();
Timer? _syncTimer;
SyncUseCase(this._syncService);
// Start periodic sync if auto-sync is enabled
void startPeriodicSync() {
if (!AppConfig.autoSync) return;
_syncTimer?.cancel();
final interval = Duration(seconds: AppConfig.syncInterval);
_syncTimer = Timer.periodic(interval, (timer) async {
if (await _isOnline()) {
await sync();
}
});
}
// Stop periodic sync
void stopPeriodicSync() {
_syncTimer?.cancel();
_syncTimer = null;
}
// Check if device is online
Future<bool> _isOnline() async {
final connectivityResult = await _connectivity.checkConnectivity();
return connectivityResult != ConnectivityResult.none;
}
// Full sync: process queue and sync from server
Future<bool> sync() async {
try {
// Ensure we have API client
final serverUrl = AppConfig.serverUrl;
final token = await AuthService.getToken();
if (serverUrl == null || token == null) {
return false;
}
final apiClient = ApiClient(baseUrl: serverUrl);
await apiClient.setAuthToken(token);
final syncService = SyncService(apiClient);
// Process sync queue (offline operations)
await syncService.processQueue();
// Sync from server (get latest data)
await syncService.syncFromServer();
return true;
} catch (e) {
print('Sync error: $e');
return false;
}
}
// Sync when connection is restored
Future<void> onConnectionRestored() async {
if (await _isOnline()) {
await sync();
}
}
}
+35
View File
@@ -0,0 +1,35 @@
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/presentation/screens/splash_screen.dart';
import 'package:timetracker_mobile/presentation/screens/login_screen.dart';
import 'package:timetracker_mobile/presentation/screens/home_screen.dart';
void main() {
runApp(
const ProviderScope(
child: TimeTrackerApp(),
),
);
}
class TimeTrackerApp extends StatelessWidget {
const TimeTrackerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TimeTracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
initialRoute: AppConstants.routeSplash,
routes: {
AppConstants.routeSplash: (context) => const SplashScreen(),
AppConstants.routeLogin: (context) => const LoginScreen(),
AppConstants.routeHome: (context) => const HomeScreen(),
},
);
}
}
@@ -0,0 +1,17 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/core/config/app_config.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
/// Provider for API client
final apiClientProvider = FutureProvider<ApiClient?>((ref) async {
final serverUrl = await AppConfig.getServerUrl();
if (serverUrl == null || serverUrl.isEmpty) {
return null;
}
return ApiClient(baseUrl: serverUrl);
});
/// Provider for API client (synchronous, requires server URL)
final apiClientSyncProvider = Provider.family<ApiClient, String>((ref, baseUrl) {
return ApiClient(baseUrl: baseUrl);
});
@@ -0,0 +1,64 @@
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';
/// Projects state
class ProjectsState {
final List<Project> projects;
final bool isLoading;
final String? error;
ProjectsState({
this.projects = const [],
this.isLoading = false,
this.error,
});
ProjectsState copyWith({
List<Project>? projects,
bool? isLoading,
String? error,
}) {
return ProjectsState(
projects: projects ?? this.projects,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// Projects notifier
class ProjectsNotifier extends StateNotifier<ProjectsState> {
final TimeTrackingRepository? repository;
ProjectsNotifier(this.repository) : super(ProjectsState()) {
if (repository != null) {
loadProjects();
}
}
Future<void> loadProjects({String? status}) async {
if (repository == null) return;
state = state.copyWith(isLoading: true, error: null);
try {
final projects = await repository!.getProjects(status: status ?? 'active');
state = state.copyWith(projects: projects, isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> refresh() async {
await loadProjects();
}
}
/// Projects provider
final projectsProvider =
StateNotifierProvider<ProjectsNotifier, ProjectsState>((ref) {
final repository = ref.watch(timeTrackingRepositoryProvider);
return ProjectsNotifier(repository);
});
@@ -0,0 +1,63 @@
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';
/// Tasks state
class TasksState {
final List<Task> tasks;
final bool isLoading;
final String? error;
TasksState({
this.tasks = const [],
this.isLoading = false,
this.error,
});
TasksState copyWith({
List<Task>? tasks,
bool? isLoading,
String? error,
}) {
return TasksState(
tasks: tasks ?? this.tasks,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// Tasks notifier
class TasksNotifier extends StateNotifier<TasksState> {
final TimeTrackingRepository? repository;
TasksNotifier(this.repository) : super(TasksState());
Future<void> loadTasks({int? projectId, String? status}) async {
if (repository == null) return;
state = state.copyWith(isLoading: true, error: null);
try {
final tasks = await repository!.getTasks(
projectId: projectId,
status: status,
);
state = state.copyWith(tasks: tasks, isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> refresh() async {
await loadTasks();
}
}
/// Tasks provider
final tasksProvider =
StateNotifierProvider<TasksNotifier, TasksState>((ref) {
final repository = ref.watch(timeTrackingRepositoryProvider);
return TasksNotifier(repository);
});
@@ -0,0 +1,208 @@
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';
/// Time entries filter state
class TimeEntriesFilter {
final int? projectId;
final String? startDate;
final String? endDate;
final bool? billable;
final int page;
final int perPage;
TimeEntriesFilter({
this.projectId,
this.startDate,
this.endDate,
this.billable,
this.page = 1,
this.perPage = 50,
});
TimeEntriesFilter copyWith({
int? projectId,
String? startDate,
String? endDate,
bool? billable,
int? page,
int? perPage,
}) {
return TimeEntriesFilter(
projectId: projectId ?? this.projectId,
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
billable: billable ?? this.billable,
page: page ?? this.page,
perPage: perPage ?? this.perPage,
);
}
}
/// Time entries state
class TimeEntriesState {
final List<TimeEntry> entries;
final bool isLoading;
final String? error;
final TimeEntriesFilter filter;
TimeEntriesState({
this.entries = const [],
this.isLoading = false,
this.error,
TimeEntriesFilter? filter,
}) : filter = filter ?? TimeEntriesFilter();
TimeEntriesState copyWith({
List<TimeEntry>? entries,
bool? isLoading,
String? error,
TimeEntriesFilter? filter,
}) {
return TimeEntriesState(
entries: entries ?? this.entries,
isLoading: isLoading ?? this.isLoading,
error: error,
filter: filter ?? this.filter,
);
}
}
/// Time entries notifier
class TimeEntriesNotifier extends StateNotifier<TimeEntriesState> {
final TimeTrackingRepository? repository;
TimeEntriesNotifier(this.repository) : super(TimeEntriesState()) {
if (repository != null) {
loadEntries();
}
}
Future<void> loadEntries({TimeEntriesFilter? filter}) async {
if (repository == null) return;
final currentFilter = filter ?? state.filter;
state = state.copyWith(isLoading: true, error: null, filter: currentFilter);
try {
final entries = await repository!.getTimeEntries(
projectId: currentFilter.projectId,
startDate: currentFilter.startDate,
endDate: currentFilter.endDate,
billable: currentFilter.billable,
page: currentFilter.page,
perPage: currentFilter.perPage,
);
state = state.copyWith(entries: entries, isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> refresh() async {
await loadEntries();
}
Future<void> loadTimeEntries({
String? startDate,
String? endDate,
}) async {
await loadEntries(
filter: state.filter.copyWith(
startDate: startDate,
endDate: endDate,
),
);
}
Future<void> setFilter(TimeEntriesFilter filter) async {
await loadEntries(filter: filter);
}
Future<void> createEntry({
required int projectId,
int? taskId,
required String startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
if (repository == null) {
state = state.copyWith(error: 'Not connected to server');
return;
}
try {
state = state.copyWith(isLoading: true, error: null);
await repository!.createTimeEntry(
projectId: projectId,
taskId: taskId,
startTime: startTime,
endTime: endTime,
notes: notes,
tags: tags,
billable: billable,
);
await loadEntries();
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> updateEntry(
int entryId, {
int? projectId,
int? taskId,
String? startTime,
String? endTime,
String? notes,
String? tags,
bool? billable,
}) async {
if (repository == null) {
state = state.copyWith(error: 'Not connected to server');
return;
}
try {
state = state.copyWith(isLoading: true, error: null);
await repository!.updateTimeEntry(
entryId,
projectId: projectId,
taskId: taskId,
startTime: startTime,
endTime: endTime,
notes: notes,
tags: tags,
billable: billable,
);
await loadEntries();
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> deleteEntry(int entryId) async {
if (repository == null) {
state = state.copyWith(error: 'Not connected to server');
return;
}
try {
state = state.copyWith(isLoading: true, error: null);
await repository!.deleteTimeEntry(entryId);
await loadEntries();
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
}
/// Time entries provider
final timeEntriesProvider =
StateNotifierProvider<TimeEntriesNotifier, TimeEntriesState>((ref) {
final repository = ref.watch(timeTrackingRepositoryProvider);
return TimeEntriesNotifier(repository);
});
@@ -0,0 +1,139 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/data/models/timer.dart';
import 'package:timetracker_mobile/domain/repositories/time_tracking_repository.dart';
import 'package:timetracker_mobile/presentation/providers/api_provider.dart';
/// Provider for time tracking repository
final timeTrackingRepositoryProvider = Provider<TimeTrackingRepository?>((ref) {
final apiClientAsync = ref.watch(apiClientProvider);
return apiClientAsync.when(
data: (apiClient) => apiClient != null ? TimeTrackingRepository(apiClient) : null,
loading: () => null,
error: (_, __) => null,
);
});
/// Timer state
class TimerState {
final Timer? timer;
final bool isLoading;
final String? error;
TimerState({
this.timer,
this.isLoading = false,
this.error,
});
TimerState copyWith({
Timer? timer,
bool? isLoading,
String? error,
}) {
return TimerState(
timer: timer ?? this.timer,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
bool get isActive => timer != null;
bool get isRunning => isActive;
Timer? get activeTimer => timer;
}
/// Timer state notifier
class TimerNotifier extends StateNotifier<TimerState> {
final TimeTrackingRepository? repository;
TimerNotifier(this.repository) : super(TimerState()) {
if (repository != null) {
_loadTimerStatus();
// Poll timer status every 5 seconds if active
_startPolling();
}
}
void _startPolling() {
Future.delayed(const Duration(seconds: 5), () {
if (state.isActive && repository != null) {
_loadTimerStatus();
_startPolling();
}
});
}
Future<void> _loadTimerStatus() async {
if (repository == null) return;
try {
state = state.copyWith(isLoading: true, error: null);
final timer = await repository!.getTimerStatus();
state = state.copyWith(timer: timer, isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> startTimer({
required int projectId,
int? taskId,
String? notes,
}) async {
if (repository == null) {
state = state.copyWith(error: 'Not connected to server');
return;
}
try {
state = state.copyWith(isLoading: true, error: null);
final timer = await repository!.startTimer(
projectId: projectId,
taskId: taskId,
notes: notes,
);
state = state.copyWith(timer: timer, isLoading: false);
// Start polling
_startPolling();
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> stopTimer() async {
if (repository == null) {
state = state.copyWith(error: 'Not connected to server');
return;
}
try {
state = state.copyWith(isLoading: true, error: null);
await repository!.stopTimer();
state = state.copyWith(timer: null, isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> refresh() async {
await _loadTimerStatus();
}
/// Get elapsed time for active timer
Duration getElapsedTime() {
if (state.timer == null || state.timer!.startTime == null) {
return Duration.zero;
}
return DateTime.now().difference(state.timer!.startTime);
}
Future<void> checkTimerStatus() async {
await _loadTimerStatus();
}
}
/// Timer provider
final timerProvider = StateNotifierProvider<TimerNotifier, TimerState>((ref) {
final repository = ref.watch(timeTrackingRepositoryProvider);
return TimerNotifier(repository);
});
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../providers/timer_provider.dart';
import '../providers/time_entries_provider.dart';
import 'timer_screen.dart';
import 'projects_screen.dart';
import 'time_entries_screen.dart';
import 'settings_screen.dart';
import 'dart:async';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
final List<Widget> _screens = [
const DashboardTab(),
const ProjectsScreen(),
const TimeEntriesScreen(),
const SettingsScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _screens[_currentIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: 'Projects',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Entries',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
floatingActionButton: _currentIndex == 0
? FloatingActionButton.extended(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TimerScreen(),
),
);
},
icon: const Icon(Icons.play_arrow),
label: const Text('Start Timer'),
)
: null,
);
}
}
class DashboardTab extends ConsumerStatefulWidget {
const DashboardTab({super.key});
@override
ConsumerState<DashboardTab> createState() => _DashboardTabState();
}
class _DashboardTabState extends ConsumerState<DashboardTab> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {});
}
});
// Load data on init
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(timerProvider.notifier).checkTimerStatus();
final now = DateTime.now();
ref.read(timeEntriesProvider.notifier).loadTimeEntries(
startDate: now.toIso8601String().split('T')[0],
endDate: now.toIso8601String().split('T')[0],
);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final timerState = ref.watch(timerProvider);
final entriesState = ref.watch(timeEntriesProvider);
// Calculate today's total
final todayTotal = entriesState.entries.fold<int>(
0,
(sum, entry) => sum + (entry.durationSeconds ?? 0),
);
final hours = todayTotal ~/ 3600;
final minutes = (todayTotal % 3600) ~/ 60;
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Active Timer Card
Card(
child: InkWell(
onTap: timerState.isRunning
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TimerScreen(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Active Timer',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (timerState.isRunning && timerState.activeTimer != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatTimer(ref.read(timerProvider.notifier).getElapsedTime()),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
'Tap to view details',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
],
)
else
Text(
'No active timer',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey,
),
),
],
),
),
),
),
const SizedBox(height: 16),
// Today's Summary Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Today\'s Summary',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
entriesState.isLoading
? 'Loading...'
: '${hours}h ${minutes}m',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
),
const SizedBox(height: 16),
Text(
'Recent Entries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (entriesState.isLoading)
const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()))
else if (entriesState.entries.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text('No recent entries'),
),
)
else
...entriesState.entries.take(5).map((entry) => Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
child: Icon(Icons.timer),
),
title: Text(entry.projectId?.toString() ?? 'Unknown Project'),
subtitle: Text(entry.notes ?? ''),
trailing: Text(entry.formattedDuration),
),
)),
],
),
),
);
}
String _formatTimer(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:timetracker_mobile/core/config/app_config.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
import 'package:timetracker_mobile/presentation/screens/timer_screen.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _serverUrlController = TextEditingController();
final _apiTokenController = TextEditingController();
final _storage = const FlutterSecureStorage();
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_loadSavedCredentials();
}
Future<void> _loadSavedCredentials() async {
final serverUrl = await AppConfig.getServerUrl();
if (serverUrl != null) {
_serverUrlController.text = serverUrl;
}
}
@override
void dispose() {
_serverUrlController.dispose();
_apiTokenController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final serverUrl = _serverUrlController.text.trim();
final apiToken = _apiTokenController.text.trim();
// Validate token format
if (!apiToken.startsWith('tt_')) {
setState(() {
_error = 'API token must start with "tt_"';
_isLoading = false;
});
return;
}
// Save credentials
await AppConfig.setServerUrl(serverUrl);
await _storage.write(key: 'api_token', value: apiToken);
// Validate connection
final apiClient = ApiClient(baseUrl: serverUrl);
await apiClient.setAuthToken(apiToken);
final isValid = await apiClient.validateToken();
if (!isValid) {
setState(() {
_error = 'Invalid API token. Please check your token.';
_isLoading = false;
});
await _storage.delete(key: 'api_token');
return;
}
// Navigate to main app
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const TimerScreen()),
);
}
} catch (e) {
setState(() {
_error = 'Connection failed: ${e.toString()}';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.timer,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
Text(
'TimeTracker',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Connect to your server',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://your-server.com',
prefixIcon: Icon(Icons.link),
),
keyboardType: TextInputType.url,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter server URL';
}
if (!Uri.tryParse(value)?.hasAbsolutePath ?? true) {
return 'Please enter a valid URL';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _apiTokenController,
decoration: const InputDecoration(
labelText: 'API Token',
hintText: 'tt_...',
prefixIcon: Icon(Icons.key),
helperText: 'Get your API token from Admin > API Tokens',
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter API token';
}
if (!value.startsWith('tt_')) {
return 'Token must start with "tt_"';
}
return null;
},
),
if (_error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
_error!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/projects_provider.dart';
import '../providers/timer_provider.dart';
import '../../data/models/project.dart';
import 'timer_screen.dart';
class ProjectsScreen extends ConsumerStatefulWidget {
const ProjectsScreen({super.key});
@override
ConsumerState<ProjectsScreen> createState() => _ProjectsScreenState();
}
class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(projectsProvider.notifier).loadProjects();
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Project> _filterProjects(List<Project> projects, String query) {
if (query.isEmpty) return projects;
return projects.where((project) {
return project.name.toLowerCase().contains(query.toLowerCase()) ||
(project.client ?? '').toLowerCase().contains(query.toLowerCase());
}).toList();
}
@override
Widget build(BuildContext context) {
final projectsState = ref.watch(projectsProvider);
final filteredProjects = _filterProjects(projectsState.projects, _searchController.text);
return Scaffold(
appBar: AppBar(
title: const Text('Projects'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Search projects...',
prefixIcon: Icon(Icons.search),
),
onChanged: (_) => setState(() {}),
),
),
Expanded(
child: projectsState.isLoading
? const Center(child: CircularProgressIndicator())
: projectsState.error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${projectsState.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(projectsProvider.notifier).loadProjects(),
child: const Text('Retry'),
),
],
),
)
: filteredProjects.isEmpty
? const Center(child: Text('No projects found'))
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filteredProjects.length,
itemBuilder: (context, index) {
final project = filteredProjects[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.folder),
),
title: Text(project.name),
subtitle: Text(project.client ?? 'No client'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
// Start timer with this project
final timerState = ref.read(timerProvider);
if (timerState.isRunning) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please stop the current timer first'),
),
);
return;
}
// Navigate to timer screen with project pre-selected
// For now, just navigate to timer screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TimerScreen(),
),
);
},
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart';
import '../../core/constants/app_constants.dart';
import '../../utils/auth/auth_service.dart';
import '../screens/login_screen.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
final serverUrl = AppConfig.serverUrl ?? 'Not configured';
final syncInterval = AppConfig.syncInterval;
final autoSync = AppConfig.autoSync;
final themeMode = AppConfig.themeMode;
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
),
body: ListView(
children: [
// Server Configuration
ListTile(
leading: const Icon(Icons.dns),
title: const Text('Server URL'),
subtitle: Text(serverUrl),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Edit server URL
},
),
const Divider(),
// API Token
ListTile(
leading: const Icon(Icons.key),
title: const Text('API Token'),
subtitle: const Text('••••••••'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Edit API token
},
),
const Divider(),
// Sync Settings
SwitchListTile(
secondary: const Icon(Icons.sync),
title: const Text('Auto Sync'),
subtitle: const Text('Automatically sync data when online'),
value: autoSync,
onChanged: (value) async {
await AppConfig.setAutoSync(value);
setState(() {});
},
),
ListTile(
leading: const Icon(Icons.schedule),
title: const Text('Sync Interval'),
subtitle: Text('$syncInterval seconds'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Edit sync interval
},
),
const Divider(),
// Theme
ListTile(
leading: const Icon(Icons.palette),
title: const Text('Theme'),
subtitle: Text(themeMode == 'system' ? 'System' : themeMode == 'dark' ? 'Dark' : 'Light'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Select theme
},
),
const Divider(),
// About
ListTile(
leading: const Icon(Icons.info),
title: const Text('About'),
subtitle: const Text('Version 1.0.0'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Show about dialog
},
),
const Divider(),
// Logout
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Logout', style: TextStyle(color: Colors.red)),
onTap: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Logout', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true && mounted) {
await AuthService.deleteToken();
await AppConfig.clear();
if (mounted) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
}
}
},
),
],
),
);
}
}
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/config/app_config.dart';
import '../../core/constants/app_constants.dart';
import 'login_screen.dart';
import 'home_screen.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
_checkAuthStatus();
}
Future<void> _checkAuthStatus() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
final serverUrl = await AppConfig.getServerUrl();
const storage = FlutterSecureStorage();
final token = await storage.read(key: AppConfig.apiTokenKey);
final hasToken = token != null && token.isNotEmpty;
if (serverUrl != null && serverUrl.isNotEmpty && hasToken) {
Navigator.of(context).pushReplacementNamed(AppConstants.routeHome);
} else {
Navigator.of(context).pushReplacementNamed(AppConstants.routeLogin);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.timer,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'TimeTracker',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 48),
const CircularProgressIndicator(),
],
),
),
);
}
}
@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:timetracker_mobile/presentation/providers/time_entries_provider.dart';
import 'package:timetracker_mobile/presentation/screens/time_entry_form_screen.dart';
import 'package:timetracker_mobile/presentation/widgets/time_entry_card.dart';
class TimeEntriesScreen extends ConsumerStatefulWidget {
const TimeEntriesScreen({super.key});
@override
ConsumerState<TimeEntriesScreen> createState() => _TimeEntriesScreenState();
}
class _TimeEntriesScreenState extends ConsumerState<TimeEntriesScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(timeEntriesProvider.notifier).loadEntries();
});
}
@override
Widget build(BuildContext context) {
final entriesState = ref.watch(timeEntriesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Time Entries'),
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: _showFilterDialog,
tooltip: 'Filter',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(timeEntriesProvider.notifier).refresh(),
tooltip: 'Refresh',
),
],
),
body: RefreshIndicator(
onRefresh: () => ref.read(timeEntriesProvider.notifier).refresh(),
child: _buildBody(entriesState),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddEntryDialog(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody(TimeEntriesState state) {
if (state.isLoading && state.entries.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.entries.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.shade300,
),
const SizedBox(height: 16),
Text(
'Error loading entries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
state.error!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(timeEntriesProvider.notifier).refresh(),
child: const Text('Retry'),
),
],
),
);
}
if (state.entries.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: Colors.grey.shade300,
),
const SizedBox(height: 16),
Text(
'No time entries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Start tracking time or add a manual entry',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: state.entries.length,
itemBuilder: (context, index) {
final entry = state.entries[index];
return TimeEntryCard(
entry: entry,
onEdit: () => _showEditEntryDialog(entry.id),
onDelete: () => _showDeleteConfirmation(entry.id),
);
},
);
}
void _showAddEntryDialog() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TimeEntryFormScreen(),
),
);
}
void _showEditEntryDialog(int entryId) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TimeEntryFormScreen(entryId: entryId),
),
);
}
void _showDeleteConfirmation(int entryId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Entry'),
content: const Text('Are you sure you want to delete this time entry?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
ref.read(timeEntriesProvider.notifier).deleteEntry(entryId);
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Delete'),
),
],
),
);
}
void _showFilterDialog() {
final currentFilter = ref.read(timeEntriesProvider).filter;
DateTime? startDate;
DateTime? endDate;
if (currentFilter.startDate != null) {
startDate = DateTime.parse(currentFilter.startDate!);
}
if (currentFilter.endDate != null) {
endDate = DateTime.parse(currentFilter.endDate!);
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filter Entries'),
content: StatefulBuilder(
builder: (context, setState) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('Start Date'),
subtitle: Text(
startDate != null
? DateFormat('yyyy-MM-dd').format(startDate)
: 'No date selected',
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: startDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() {
startDate = date;
});
}
},
),
ListTile(
title: const Text('End Date'),
subtitle: Text(
endDate != null
? DateFormat('yyyy-MM-dd').format(endDate)
: 'No date selected',
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: endDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() {
endDate = date;
});
}
},
),
],
);
},
),
actions: [
TextButton(
onPressed: () {
// Clear filters
ref.read(timeEntriesProvider.notifier).setFilter(
TimeEntriesFilter(),
);
Navigator.of(context).pop();
},
child: const Text('Clear'),
),
ElevatedButton(
onPressed: () {
ref.read(timeEntriesProvider.notifier).setFilter(
currentFilter.copyWith(
startDate: startDate != null
? DateFormat('yyyy-MM-dd').format(startDate)
: null,
endDate: endDate != null
? DateFormat('yyyy-MM-dd').format(endDate)
: null,
),
);
Navigator.of(context).pop();
},
child: const Text('Apply'),
),
],
),
);
}
}
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:timetracker_mobile/data/models/time_entry.dart';
import 'package:timetracker_mobile/presentation/providers/projects_provider.dart';
import 'package:timetracker_mobile/presentation/providers/tasks_provider.dart';
import 'package:timetracker_mobile/presentation/providers/time_entries_provider.dart';
class TimeEntryFormScreen extends ConsumerStatefulWidget {
final int? entryId;
const TimeEntryFormScreen({super.key, this.entryId});
@override
ConsumerState<TimeEntryFormScreen> createState() =>
_TimeEntryFormScreenState();
}
class _TimeEntryFormScreenState extends ConsumerState<TimeEntryFormScreen> {
final _formKey = GlobalKey<FormState>();
int? _selectedProjectId;
int? _selectedTaskId;
DateTime _startDate = DateTime.now();
TimeOfDay _startTime = TimeOfDay.now();
DateTime? _endDate;
TimeOfDay? _endTime;
final _notesController = TextEditingController();
final _tagsController = TextEditingController();
bool _billable = true;
bool _isLoading = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(projectsProvider.notifier).loadProjects();
if (widget.entryId != null) {
_loadEntry();
}
});
}
Future<void> _loadEntry() async {
// Load entry details and populate form
// This would require a getTimeEntry method in the provider
// For now, we'll just handle creation
}
@override
void dispose() {
_notesController.dispose();
_tagsController.dispose();
super.dispose();
}
Future<void> _loadTasks(int projectId) async {
await ref.read(tasksProvider.notifier).loadTasks(projectId: projectId);
}
Future<void> _selectDateTime(
BuildContext context, {
required bool isStart,
}) async {
final date = await showDatePicker(
context: context,
initialDate: isStart ? _startDate : (_endDate ?? DateTime.now()),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (date == null) return;
final time = await showTimePicker(
context: context,
initialTime: isStart ? _startTime : (_endTime ?? TimeOfDay.now()),
);
if (time == null) return;
setState(() {
if (isStart) {
_startDate = date;
_startTime = time;
} else {
_endDate = date;
_endTime = time;
}
});
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedProjectId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a project')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
final startDateTime = DateTime(
_startDate.year,
_startDate.month,
_startDate.day,
_startTime.hour,
_startTime.minute,
);
String? endDateTimeStr;
if (_endDate != null && _endTime != null) {
final endDateTime = DateTime(
_endDate!.year,
_endDate!.month,
_endDate!.day,
_endTime!.hour,
_endTime!.minute,
);
endDateTimeStr = endDateTime.toIso8601String();
}
if (widget.entryId != null) {
await ref.read(timeEntriesProvider.notifier).updateEntry(
widget.entryId!,
projectId: _selectedProjectId,
taskId: _selectedTaskId,
startTime: startDateTime.toIso8601String(),
endTime: endDateTimeStr,
notes: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
tags: _tagsController.text.trim().isEmpty
? null
: _tagsController.text.trim(),
billable: _billable,
);
} else {
await ref.read(timeEntriesProvider.notifier).createEntry(
projectId: _selectedProjectId!,
taskId: _selectedTaskId,
startTime: startDateTime.toIso8601String(),
endTime: endDateTimeStr,
notes: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
tags: _tagsController.text.trim().isEmpty
? null
: _tagsController.text.trim(),
billable: _billable,
);
}
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final projectsState = ref.watch(projectsProvider);
final tasksState = ref.watch(tasksProvider);
return Scaffold(
appBar: AppBar(
title: Text(widget.entryId != null ? 'Edit Entry' : 'New Entry'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Project selection
DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Project *',
prefixIcon: Icon(Icons.folder),
),
value: _selectedProjectId,
items: projectsState.projects
.map((p) => DropdownMenuItem(
value: p.id,
child: Text(p.name),
))
.toList(),
onChanged: (value) {
setState(() {
_selectedProjectId = value;
_selectedTaskId = null;
});
if (value != null) {
_loadTasks(value);
}
},
validator: (value) {
if (value == null) {
return 'Please select a project';
}
return null;
},
),
const SizedBox(height: 16),
// Task selection
if (_selectedProjectId != null)
DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Task (Optional)',
prefixIcon: Icon(Icons.task),
),
value: _selectedTaskId,
items: [
const DropdownMenuItem<int>(
value: null,
child: Text('No task'),
),
...tasksState.tasks
.map((t) => DropdownMenuItem(
value: t.id,
child: Text(t.name),
))
.toList(),
],
onChanged: (value) {
setState(() {
_selectedTaskId = value;
});
},
),
const SizedBox(height: 16),
// Start date/time
ListTile(
title: const Text('Start Date & Time *'),
subtitle: Text(
DateFormat('yyyy-MM-dd HH:mm').format(
DateTime(
_startDate.year,
_startDate.month,
_startDate.day,
_startTime.hour,
_startTime.minute,
),
),
),
trailing: const Icon(Icons.calendar_today),
onTap: () => _selectDateTime(context, isStart: true),
),
const SizedBox(height: 16),
// End date/time
ListTile(
title: const Text('End Date & Time (Optional)'),
subtitle: Text(
_endDate != null && _endTime != null
? DateFormat('yyyy-MM-dd HH:mm').format(
DateTime(
_endDate!.year,
_endDate!.month,
_endDate!.day,
_endTime!.hour,
_endTime!.minute,
),
)
: 'Not set',
),
trailing: const Icon(Icons.calendar_today),
onTap: () => _selectDateTime(context, isStart: false),
),
const SizedBox(height: 16),
// Notes
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
prefixIcon: Icon(Icons.note),
),
maxLines: 3,
),
const SizedBox(height: 16),
// Tags
TextFormField(
controller: _tagsController,
decoration: const InputDecoration(
labelText: 'Tags (comma-separated)',
prefixIcon: Icon(Icons.tag),
),
),
const SizedBox(height: 16),
// Billable checkbox
CheckboxListTile(
title: const Text('Billable'),
value: _billable,
onChanged: (value) {
setState(() {
_billable = value ?? true;
});
},
),
const SizedBox(height: 24),
// Submit button
ElevatedButton(
onPressed: _isLoading ? null : _handleSubmit,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(widget.entryId != null ? 'Update Entry' : 'Create Entry'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:timetracker_mobile/core/config/app_config.dart';
import 'package:timetracker_mobile/presentation/screens/login_screen.dart';
import 'package:timetracker_mobile/presentation/screens/time_entries_screen.dart';
import 'package:timetracker_mobile/presentation/widgets/timer_widget.dart';
class TimerScreen extends ConsumerStatefulWidget {
const TimerScreen({super.key});
@override
ConsumerState<TimerScreen> createState() => _TimerScreenState();
}
class _TimerScreenState extends ConsumerState<TimerScreen> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TimeTracker'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: _handleLogout,
tooltip: 'Logout',
),
],
),
body: IndexedStack(
index: _selectedIndex,
children: const [
_TimerTab(),
TimeEntriesScreen(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.timer_outlined),
selectedIcon: Icon(Icons.timer),
label: 'Timer',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Entries',
),
],
),
);
}
Future<void> _handleLogout() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
),
);
if (confirmed == true) {
const storage = FlutterSecureStorage();
await storage.delete(key: 'api_token');
await AppConfig.clearServerUrl();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
}
}
}
class _TimerTab extends ConsumerWidget {
const _TimerTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
TimerWidget(),
],
),
);
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:timetracker_mobile/data/models/time_entry.dart';
class TimeEntryCard extends StatelessWidget {
final TimeEntry entry;
final VoidCallback? onTap;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const TimeEntryCard({
super.key,
required this.entry,
this.onTap,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.project?.name ?? 'Unknown Project',
style: Theme.of(context).textTheme.titleMedium,
),
if (entry.task != null) ...[
const SizedBox(height: 4),
Text(
entry.task!.name,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Text(
entry.formattedDuration,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: Colors.grey.shade600,
),
const SizedBox(width: 4),
Text(
entry.formattedDateRange,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
if (entry.billable) ...[
const SizedBox(width: 16),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Billable',
style: TextStyle(
fontSize: 10,
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
if (entry.notes != null && entry.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
entry.notes!,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (onEdit != null || onDelete != null) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (onEdit != null)
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 18),
label: const Text('Edit'),
),
if (onDelete != null)
TextButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete, size: 18),
label: const Text('Delete'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
),
],
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/presentation/providers/timer_provider.dart';
class TimerWidget extends ConsumerStatefulWidget {
const TimerWidget({super.key});
@override
ConsumerState<TimerWidget> createState() => _TimerWidgetState();
}
class _TimerWidgetState extends ConsumerState<TimerWidget> {
@override
void initState() {
super.initState();
// Refresh timer every second when active
WidgetsBinding.instance.addPostFrameCallback((_) {
_startTimerUpdate();
});
}
void _startTimerUpdate() {
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
final timerState = ref.read(timerProvider);
if (timerState.isActive) {
setState(() {});
_startTimerUpdate();
}
}
});
}
@override
Widget build(BuildContext context) {
final timerState = ref.watch(timerProvider);
return Card(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Text(
'Active Timer',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
if (timerState.isActive && timerState.timer != null)
Column(
children: [
Text(
timerState.timer!.formattedElapsed,
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
timerState.timer!.project?.name ?? 'Unknown Project',
style: Theme.of(context).textTheme.titleMedium,
),
if (timerState.timer!.task != null) ...[
const SizedBox(height: 4),
Text(
timerState.timer!.task!.name,
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (timerState.timer!.notes != null &&
timerState.timer!.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
timerState.timer!.notes!,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: timerState.isLoading
? null
: () => ref.read(timerProvider.notifier).stopTimer(),
icon: const Icon(Icons.stop),
label: const Text('Stop Timer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
)
else
Column(
children: [
Text(
'00:00:00',
style: Theme.of(context).textTheme.displayLarge?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
'No active timer',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: timerState.isLoading
? null
: () => _showStartTimerDialog(context),
icon: const Icon(Icons.play_arrow),
label: const Text('Start Timer'),
),
],
),
if (timerState.error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
timerState.error!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
],
),
),
);
}
void _showStartTimerDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => const StartTimerDialog(),
);
}
}
class StartTimerDialog extends ConsumerStatefulWidget {
const StartTimerDialog({super.key});
@override
ConsumerState<StartTimerDialog> createState() => _StartTimerDialogState();
}
class _StartTimerDialogState extends ConsumerState<StartTimerDialog> {
int? _selectedProjectId;
int? _selectedTaskId;
final _notesController = TextEditingController();
@override
void initState() {
super.initState();
// Load projects if not loaded
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(projectsProvider.notifier).loadProjects();
});
}
@override
void dispose() {
_notesController.dispose();
super.dispose();
}
Future<void> _loadTasks(int projectId) async {
await ref.read(tasksProvider.notifier).loadTasks(projectId: projectId);
}
Future<void> _handleStart() async {
if (_selectedProjectId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a project')),
);
return;
}
await ref.read(timerProvider.notifier).startTimer(
projectId: _selectedProjectId!,
taskId: _selectedTaskId,
notes: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
);
if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final projectsState = ref.watch(projectsProvider);
final tasksState = ref.watch(tasksProvider);
return AlertDialog(
title: const Text('Start Timer'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Project selection
DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Project',
prefixIcon: Icon(Icons.folder),
),
value: _selectedProjectId,
items: projectsState.projects
.map((p) => DropdownMenuItem(
value: p.id,
child: Text(p.name),
))
.toList(),
onChanged: (value) {
setState(() {
_selectedProjectId = value;
_selectedTaskId = null; // Reset task when project changes
});
if (value != null) {
_loadTasks(value);
}
},
),
const SizedBox(height: 16),
// Task selection (optional)
if (_selectedProjectId != null)
DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Task (Optional)',
prefixIcon: Icon(Icons.task),
),
value: _selectedTaskId,
items: [
const DropdownMenuItem<int>(
value: null,
child: Text('No task'),
),
...tasksState.tasks
.map((t) => DropdownMenuItem(
value: t.id,
child: Text(t.name),
))
.toList(),
],
onChanged: (value) {
setState(() {
_selectedTaskId = value;
});
},
),
const SizedBox(height: 16),
// Notes
TextField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes (Optional)',
prefixIcon: Icon(Icons.note),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _handleStart,
child: const Text('Start'),
),
],
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class AuthService {
static const _storage = FlutterSecureStorage();
static const String _keyApiToken = 'api_token';
// Store API token securely
static Future<void> storeToken(String token) async {
await _storage.write(key: _keyApiToken, value: token);
}
// Retrieve API token
static Future<String?> getToken() async {
return await _storage.read(key: _keyApiToken);
}
// Delete API token (logout)
static Future<void> deleteToken() async {
await _storage.delete(key: _keyApiToken);
}
// Check if token exists
static Future<bool> hasToken() async {
final token = await getToken();
return token != null && token.isNotEmpty;
}
}