mobile base

This commit is contained in:
2026-02-11 15:58:30 +01:00
parent e03329cedb
commit e254554532
14 changed files with 1007 additions and 2 deletions

View File

@@ -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")
}

View File

@@ -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>

View File

@@ -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
)
}
}
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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) }
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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()
}
}
}
}