diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts
index 0d01f81..ed12121 100644
--- a/app/app/build.gradle.kts
+++ b/app/app/build.gradle.kts
@@ -38,6 +38,7 @@ android {
buildFeatures {
compose = true
+ buildConfig = true
}
}
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
index 03d7e28..39680d7 100644
--- a/app/app/src/main/AndroidManifest.xml
+++ b/app/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
+
+
+
+
diff --git a/app/app/src/main/java/de/haushalt/app/StartScreen.kt b/app/app/src/main/java/de/haushalt/app/StartScreen.kt
index d4039f9..db69233 100644
--- a/app/app/src/main/java/de/haushalt/app/StartScreen.kt
+++ b/app/app/src/main/java/de/haushalt/app/StartScreen.kt
@@ -1,42 +1,121 @@
package de.haushalt.app
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
+import de.haushalt.app.data.AppUpdater
+import de.haushalt.app.data.AppVersion
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private sealed interface UpdateState {
+ data object Idle : UpdateState
+ data object Checking : UpdateState
+ data object NoUpdate : UpdateState
+ data class Available(val version: AppVersion) : UpdateState
+ data object Downloading : UpdateState
+ data class Error(val message: String) : UpdateState
+}
@Composable
fun StartScreen(onOpenTasks: () -> Unit = {}) {
- LazyVerticalGrid(
- columns = GridCells.Adaptive(minSize = 160.dp),
- modifier = Modifier
- .fillMaxSize()
- .padding(16.dp),
- ) {
- item {
- Card(
- onClick = onOpenTasks,
- modifier = Modifier
- .padding(8.dp)
- .aspectRatio(1f),
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center,
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var updateState by remember { mutableStateOf(UpdateState.Idle) }
+
+ Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 160.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ item {
+ Card(
+ onClick = onOpenTasks,
+ modifier = Modifier
+ .padding(8.dp)
+ .aspectRatio(1f),
) {
- Text(
- text = "Tasks",
- style = MaterialTheme.typography.titleLarge,
- )
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "Tasks",
+ style = MaterialTheme.typography.titleLarge,
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.padding(8.dp))
+
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ when (val state = updateState) {
+ UpdateState.Idle -> TextButton(onClick = {
+ scope.launch {
+ updateState = UpdateState.Checking
+ try {
+ val version = withContext(Dispatchers.IO) { AppUpdater.checkUpdate() }
+ updateState = if (version != null) UpdateState.Available(version)
+ else UpdateState.NoUpdate
+ } catch (e: Exception) {
+ updateState = UpdateState.Error(e.message ?: "Fehler")
+ }
+ }
+ }) { Text("Update prüfen") }
+
+ UpdateState.Checking -> Text("Prüfe…", style = MaterialTheme.typography.bodySmall)
+
+ UpdateState.NoUpdate -> Text(
+ "App ist aktuell",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ is UpdateState.Available -> Button(onClick = {
+ scope.launch {
+ updateState = UpdateState.Downloading
+ try {
+ withContext(Dispatchers.IO) {
+ AppUpdater.downloadAndInstall(context, state.version)
+ }
+ updateState = UpdateState.Idle
+ } catch (e: Exception) {
+ updateState = UpdateState.Error(e.message ?: "Fehler")
+ }
+ }
+ }) { Text("Update installieren (v${state.version.versionCode})") }
+
+ UpdateState.Downloading -> Text(
+ "Lade herunter…",
+ style = MaterialTheme.typography.bodySmall,
+ )
+
+ is UpdateState.Error -> TextButton(onClick = { updateState = UpdateState.Idle }) {
+ Text(state.message, color = MaterialTheme.colorScheme.error)
}
}
}
diff --git a/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt b/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt
index 52943a8..8890602 100644
--- a/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt
+++ b/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt
@@ -8,23 +8,32 @@ import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
object ApiClient {
- private const val BASE_URL = "http://192.168.178.34:8080/api/"
+ const val BASE_URL = "http://192.168.178.34:8080/"
+ private const val API_URL = "${BASE_URL}api/"
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
- private val okHttp: OkHttpClient = OkHttpClient.Builder()
+ val okHttp: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
)
.build()
- val taskApi: TaskApi = Retrofit.Builder()
+ private val retrofit: Retrofit = Retrofit.Builder()
+ .baseUrl(API_URL)
+ .client(okHttp)
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+
+ val taskApi: TaskApi = retrofit.create(TaskApi::class.java)
+
+ val appUpdateApi: AppUpdateApi = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
- .create(TaskApi::class.java)
+ .create(AppUpdateApi::class.java)
}
diff --git a/app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt b/app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt
new file mode 100644
index 0000000..7fe928f
--- /dev/null
+++ b/app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt
@@ -0,0 +1,15 @@
+package de.haushalt.app.data
+
+import kotlinx.serialization.Serializable
+import retrofit2.http.GET
+
+interface AppUpdateApi {
+ @GET("app/version.json")
+ suspend fun getVersion(): AppVersion
+}
+
+@Serializable
+data class AppVersion(
+ val versionCode: Int,
+ val apkFile: String,
+)
diff --git a/app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt b/app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt
new file mode 100644
index 0000000..98b1558
--- /dev/null
+++ b/app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt
@@ -0,0 +1,36 @@
+package de.haushalt.app.data
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import de.haushalt.app.BuildConfig
+import okhttp3.Request
+import java.io.File
+
+object AppUpdater {
+ suspend fun checkUpdate(): AppVersion? {
+ val remote = ApiClient.appUpdateApi.getVersion()
+ return if (remote.versionCode > BuildConfig.VERSION_CODE) remote else null
+ }
+
+ fun downloadAndInstall(context: Context, version: AppVersion) {
+ val apkUrl = "${ApiClient.BASE_URL}app/${version.apkFile}"
+ val apkFile = File(context.cacheDir, "apk/haushalt.apk")
+ apkFile.parentFile?.mkdirs()
+
+ val request = Request.Builder().url(apkUrl).build()
+ ApiClient.okHttp.newCall(request).execute().use { response ->
+ apkFile.outputStream().use { out ->
+ response.body!!.byteStream().copyTo(out)
+ }
+ }
+
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+}
diff --git a/app/app/src/main/res/xml/file_paths.xml b/app/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..18d7a35
--- /dev/null
+++ b/app/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/backend/public/app/version.json b/backend/public/app/version.json
new file mode 100644
index 0000000..0644ac1
--- /dev/null
+++ b/backend/public/app/version.json
@@ -0,0 +1,4 @@
+{
+ "versionCode": 1,
+ "apkFile": "haushalt.apk"
+}
diff --git a/module.md b/module.md
index 0f2a065..2db9963 100644
--- a/module.md
+++ b/module.md
@@ -74,6 +74,39 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
- TaskCreate page: create task
- TaskEdit page: update task
+# App update module
+## Backend
+- public/app/version.json - version info (versionCode, apkFile)
+- public/app/haushalt.apk - current APK (manually deployed)
+## App
+- AppUpdateApi.kt - version check endpoint
+- AppUpdater.kt - check version, download APK, trigger install
+- StartScreen.kt - "Update prüfen" button with state feedback
+- AndroidManifest.xml - REQUEST_INSTALL_PACKAGES, FileProvider
+- file_paths.xml - cache path for downloaded APK
+## Features
+- Manual update check from start screen
+- Download and install new APK from server
+- No Play Store required
+
+# TaskSchema module
+## Backend
+- TaskSchema - TaskSchema entity
+ - id, name, status, date, type, repeat, start, end
+- TaskType - Enum for schema type
+ - single (create one task on date or null, delete schema after date or now if null)
+ - repeat (create tasks depending on repeat in start-end range, delete after enddate)
+- TaskRepeat - Enum for schema repeat
+ - daily, weekly (array with weekdays), monthly (array with monthdays)
+- TaskSchemaController - TaskSchema routes
+ - index, show, create, update, delete
+- TaskSchemaManager - TaskSchema CRUD
+ - create, update, delete
+- TaskSchemaRepository - TaskSchema queries
+- TaskGenerator - Create tasks from schema
+ - generate
+
+
# Category module
## Backend
- Category - Category entity
@@ -86,17 +119,4 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
- TaskSchemaManager - TaskSchema CRUD
- TaskSchemaRepository - TaskSchema queries
-# TaskSchema module
-- TaskSchema - TaskSchema entity
- - id, name, status, category, start, end, date
- - type(single, repeat, custom),
- - repeat(daily, weekly, monthly, yearly)
- - custom(days)
-- TaskSchemaController - TaskSchema routes
- - index, show, create, update, delete
-- TaskSchemaManager - TaskSchema CRUD
- - create, update, delete
-- TaskSchemaRepository - TaskSchema queries
-- TaskGenerator - Create tasks from schema
- - generate