mobile base
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
// Kotlin 2.0+ requires the dedicated Compose Compiler plugin
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
// Version constraint removed to allow sync with main Kotlin version (2.0.0+)
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
|
||||
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -33,6 +39,10 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -43,4 +53,22 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
||||
|
||||
// Storage
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
|
||||
// Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2024.02.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -11,6 +13,19 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.OpenTimeTracker"
|
||||
tools:targetApi="31" />
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name="MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.OpenTimeTracker">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,236 @@
|
||||
package calvin.erfmann.opentimetracker
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import calvin.erfmann.opentimetracker.data.Activity
|
||||
import calvin.erfmann.opentimetracker.data.UserPreferences
|
||||
import calvin.erfmann.opentimetracker.di.NetworkModule
|
||||
import calvin.erfmann.opentimetracker.ui.AuthViewModel
|
||||
import calvin.erfmann.opentimetracker.ui.DashboardViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val prefs = UserPreferences(this)
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
AppNavigation(prefs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(prefs: UserPreferences) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "login") {
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
viewModel = viewModel(factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return AuthViewModel(prefs) as T
|
||||
}
|
||||
}),
|
||||
onLoginSuccess = { navController.navigate("dashboard") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}}
|
||||
)
|
||||
}
|
||||
composable("dashboard") {
|
||||
// Using remember to avoid recreating the API definition
|
||||
val api = remember { NetworkModule.provideApiService(prefs) }
|
||||
|
||||
// CORRECT: Using viewModel factory to keep the instance alive across recompositions
|
||||
// This prevents the infinite loop of API requests
|
||||
val viewModel: DashboardViewModel = viewModel(
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return DashboardViewModel(api) as T
|
||||
}
|
||||
}
|
||||
)
|
||||
DashboardScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(viewModel: AuthViewModel, onLoginSuccess: () -> Unit) {
|
||||
|
||||
if (viewModel.isLoggedIn.value) {
|
||||
LaunchedEffect(Unit) { onLoginSuccess() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Open Time Tracker", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = viewModel.baseUrlInput.value,
|
||||
onValueChange = { viewModel.baseUrlInput.value = it },
|
||||
label = { Text("Server URL (Self-hosted)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = viewModel.username.value,
|
||||
onValueChange = { viewModel.username.value = it },
|
||||
label = { Text("Username") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = viewModel.password.value,
|
||||
onValueChange = { viewModel.password.value = it },
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (viewModel.error.value != null) {
|
||||
Text(viewModel.error.value!!, color = Color.Red)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.login() },
|
||||
enabled = !viewModel.isLoading.value,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (viewModel.isLoading.value) "Logging in..." else "Login")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen(viewModel: DashboardViewModel) {
|
||||
val state = viewModel.state.value
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// --- Timer Section ---
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (state?.isTracking == true) {
|
||||
val activityName = state.activities.find { it.id == state.entry?.activityId }?.name ?: "Unknown"
|
||||
Text(activityName, style = MaterialTheme.typography.titleMedium)
|
||||
if (!state.entry?.subcategory.isNullOrEmpty()) {
|
||||
SuggestionChip(onClick = {}, label = { Text(state.entry?.subcategory!!) })
|
||||
}
|
||||
Text(viewModel.elapsedTime.value, style = MaterialTheme.typography.displayLarge)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = { viewModel.stopActivity() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text("STOP")
|
||||
}
|
||||
} else {
|
||||
Text("Ready to work?", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Select an activity below", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// --- Content Switching ---
|
||||
// Always show Active Tasks if present
|
||||
if (!state?.activeTasks.isNullOrEmpty()) {
|
||||
Text("Active Tasks", style = MaterialTheme.typography.titleMedium)
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) { // Limit height to not push activities off screen
|
||||
items(state!!.activeTasks) { task ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = task.status == "completed",
|
||||
onCheckedChange = { /* Call toggle api */ })
|
||||
Text(task.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Always show Activities Grid
|
||||
Text("Activities", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Show loading or empty state if null
|
||||
if (state == null) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(100.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f) // Fill remaining space
|
||||
) {
|
||||
items(state.activities) { activity ->
|
||||
ActivityCard(activity) { viewModel.startActivity(activity.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActivityCard(activity: Activity, onClick: () -> Unit) {
|
||||
// Parse hex color lightly
|
||||
val color = try {
|
||||
Color(android.graphics.Color.parseColor(activity.color))
|
||||
} catch (e: Exception) { Color.Gray }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.height(100.dp)
|
||||
.clickable { onClick() },
|
||||
colors = CardDefaults.cardColors(containerColor = color)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
activity.name,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package calvin.erfmann.opentimetracker.data
|
||||
|
||||
import retrofit2.http.*
|
||||
|
||||
interface ApiService {
|
||||
// Auth
|
||||
@POST("api/login")
|
||||
suspend fun login(@Body request: LoginRequest): AuthResponse
|
||||
|
||||
// Dashboard
|
||||
@GET("api/status")
|
||||
suspend fun getStatus(): StatusResponse
|
||||
|
||||
@POST("api/timer/start/{activity_id}")
|
||||
suspend fun startActivity(@Path("activity_id") activityId: String): StartTimerResponse
|
||||
|
||||
@POST("api/timer/stop")
|
||||
suspend fun stopActivity()
|
||||
|
||||
@POST("api/tasks/toggle")
|
||||
suspend fun toggleTask(@Body body: Map<String, Any>): Any // Simplified
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package calvin.erfmann.opentimetracker.data
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
|
||||
|
||||
@Serializable
|
||||
data class AuthResponse(
|
||||
val token: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatusResponse(
|
||||
@SerialName("is_tracking") val isTracking: Boolean = false,
|
||||
val entry: TimeEntry? = null,
|
||||
@SerialName("active_tasks") val activeTasks: List<Task> = emptyList(),
|
||||
val activities: List<Activity> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimeEntry(
|
||||
val id: String,
|
||||
@SerialName("start_time") val startTime: String,
|
||||
val note: String? = null,
|
||||
val subcategory: String? = null,
|
||||
@SerialName("activity_id") val activityId: String? = null,
|
||||
@SerialName("activity_name") val activityName: String? = null,
|
||||
@SerialName("activity_color") val activityColor: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Activity(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val color: String,
|
||||
val subcategories: List<String> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Task(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String = "open", // Werte vom Server: "open" oder "completed"
|
||||
val subcategory: String? = null,
|
||||
@SerialName("activity_id") val activityId: String? = null,
|
||||
@SerialName("completed_at") val completedAt: String? = null,
|
||||
@SerialName("due_date") val dueDate: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StartTimerResponse(
|
||||
val id: String,
|
||||
@SerialName("start_time") val startTime: String
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package calvin.erfmann.opentimetracker.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "settings")
|
||||
|
||||
class UserPreferences(private val context: Context) {
|
||||
companion object {
|
||||
val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||
val KEY_JWT = stringPreferencesKey("jwt")
|
||||
const val DEFAULT_URL = "https://opentimetracker.app/"
|
||||
}
|
||||
|
||||
val baseUrl: Flow<String> = context.dataStore.data.map { it[KEY_BASE_URL] ?: DEFAULT_URL }
|
||||
val authToken: Flow<String?> = context.dataStore.data.map { it[KEY_JWT] }
|
||||
|
||||
suspend fun saveBaseUrl(url: String) {
|
||||
// Ensure trailing slash
|
||||
val formattedUrl = if (url.endsWith("/")) url else "$url/"
|
||||
context.dataStore.edit { it[KEY_BASE_URL] = formattedUrl }
|
||||
}
|
||||
|
||||
suspend fun saveToken(token: String) {
|
||||
context.dataStore.edit { it[KEY_JWT] = token }
|
||||
}
|
||||
|
||||
suspend fun clearToken() {
|
||||
context.dataStore.edit { it.remove(KEY_JWT) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package calvin.erfmann.opentimetracker.di
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import calvin.erfmann.opentimetracker.data.ApiService
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Interceptor
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import calvin.erfmann.opentimetracker.data.UserPreferences
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
|
||||
object NetworkModule {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
|
||||
fun provideApiService(preferences: UserPreferences): ApiService {
|
||||
val authInterceptor = Interceptor { chain ->
|
||||
val token = runBlocking { preferences.authToken.first() }
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
if (token != null) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
chain.proceed(requestBuilder.build())
|
||||
}
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// Note: For dynamic Base URL changes in a real App, you'd reimplement this
|
||||
// to rebuild Retrofit when prefs change, or use an OkHttp Interceptor to switch Host.
|
||||
// For simplicity, we assume URL is set before Login and app restarts or re-initis logic.
|
||||
val currentUrl = runBlocking { preferences.baseUrl.first() }
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(currentUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(ApiService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package calvin.erfmann.opentimetracker.ui
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import calvin.erfmann.opentimetracker.data.LoginRequest
|
||||
import calvin.erfmann.opentimetracker.data.UserPreferences
|
||||
import calvin.erfmann.opentimetracker.di.NetworkModule
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AuthViewModel(private val prefs: UserPreferences) : ViewModel() {
|
||||
var baseUrlInput = mutableStateOf(UserPreferences.DEFAULT_URL)
|
||||
var username = mutableStateOf("")
|
||||
var password = mutableStateOf("")
|
||||
var isLoading = mutableStateOf(false)
|
||||
var error = mutableStateOf<String?>(null)
|
||||
var isLoggedIn = mutableStateOf(false)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
prefs.baseUrl.collect { baseUrlInput.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun login() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
// 1. Save URL first so NetworkModule picks it up
|
||||
prefs.saveBaseUrl(baseUrlInput.value)
|
||||
|
||||
// 2. Create temp service
|
||||
val api = NetworkModule.provideApiService(prefs)
|
||||
|
||||
// 3. Call Login
|
||||
val response = api.login(LoginRequest(username.value, password.value))
|
||||
prefs.saveToken(response.token)
|
||||
isLoggedIn.value = true
|
||||
} catch (e: Exception) {
|
||||
error.value = "Login failed: ${e.message}"
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package calvin.erfmann.opentimetracker.ui
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import calvin.erfmann.opentimetracker.data.ApiService
|
||||
import calvin.erfmann.opentimetracker.data.StatusResponse
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
class DashboardViewModel(private val api: ApiService) : ViewModel() {
|
||||
|
||||
var state = mutableStateOf<StatusResponse?>(null)
|
||||
var elapsedTime = mutableStateOf("00:00:00")
|
||||
|
||||
init {
|
||||
startPolling()
|
||||
startLocalTimer()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
viewModelScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
val status = api.getStatus()
|
||||
state.value = status
|
||||
} catch (e: Exception) {
|
||||
// Handle error (e.g., 401 -> Logout)
|
||||
e.printStackTrace()
|
||||
}
|
||||
delay(3000) // Poll every 3 seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocalTimer() {
|
||||
viewModelScope.launch {
|
||||
while (isActive) {
|
||||
state.value?.entry?.startTime?.let { isoTime ->
|
||||
try {
|
||||
// Assuming ISO format like "2023-10-27T10:00:00Z"
|
||||
val start = Instant.parse(isoTime)
|
||||
val now = Instant.now()
|
||||
val seconds = Duration.between(start, now).seconds
|
||||
if (seconds >= 0) {
|
||||
val h = seconds / 3600
|
||||
val m = (seconds % 3600) / 60
|
||||
val s = seconds % 60
|
||||
elapsedTime.value = String.format("%02d:%02d:%02d", h, m, s)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
elapsedTime.value = "--:--"
|
||||
}
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startActivity(activityId: String) {
|
||||
viewModelScope.launch {
|
||||
// Optimistic UI Update possible here
|
||||
try {
|
||||
api.startActivity(activityId)
|
||||
// Polling will update the full state shortly
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopActivity() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
api.stopActivity()
|
||||
// Explicitly refresh state immediately for better UX
|
||||
state.value = api.getStatus()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user