mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
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:
@@ -15,6 +15,7 @@ downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
!mobile/lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user