diff --git a/README.md b/README.md
index a36926c..8105cf9 100644
--- a/README.md
+++ b/README.md
@@ -76,11 +76,11 @@ Everything is data-driven. New buttons, screens, and branching logic are configu
| function_id | behaviour |
|---|---|
-| `show_call_confirm` | Shows a confirm/cancel screen. Emits `confirmed`. Cancel always ends the workflow. |
| `show_speaker_choice` | Shows speakerphone vs normal choice. Emits `speakerphone` or `normal`. |
| `call_contact` | Dials `phone` from `parameter_json`. Reads `speakerphone` from `parameter_json` (bool), falls back to `lastResultCode == "speakerphone"`. |
-| `show_reminder` | Renders `ContentScreen` with the step's `content_block` list. Advances with `null` when the patient taps the button. |
-| `show_tutorial` | Same as `show_reminder` — mechanically identical. Both render `ContentScreen`. |
+| `open_app` | Launches `package_name` from `parameter_json` via `packageManager.getLaunchIntentForPackage`. Advances with `null`. Shows a toast if the app isn't installed. |
+| `show_content` | Renders `ContentScreen` with the step's `content_block` list (heading/text/image/button). A button block advances with its own `result_code`; `result_code == "cancel"` always ends the workflow regardless of step type. With no button block, a default "Next" button advances with `null`. |
+| `show_app_list` | Renders `AppListScreen` — a scrolling grid of every `workflow_type == "app"` workflow for the device (the equivalent of a stock launcher's swipe-up app drawer). Tapping a tile starts that workflow directly, same as a home-grid button. |
| `if` | Checks `lastResultCode == if_result_code`. Jumps to `then_step` if true, `else_step` if false. Falls back to next by sort_order if targets not set. |
| `label` | No-op. Used as a named jump target for `if` steps. Advances to next step by sort_order. |
@@ -90,8 +90,7 @@ Everything is data-driven. New buttons, screens, and branching logic are configu
| result_code | emitted by |
|---|---|
-| `confirmed` | `show_call_confirm` (user tapped confirm) |
-| `cancelled` | `show_call_confirm` or `show_speaker_choice` (user tapped cancel) |
+| `cancel` | any `show_content` button tagged as cancel, or `show_speaker_choice` (user tapped cancel) — always ends the workflow |
| `speakerphone` | `show_speaker_choice` |
| `normal` | `show_speaker_choice` |
@@ -167,22 +166,26 @@ Everything is data-driven. New buttons, screens, and branching logic are configu
| function_id | name |
|---|---|
-| `show_call_confirm` | Show Call Confirm |
| `show_speaker_choice` | Show Speaker Choice |
| `call_contact` | Call Contact |
+| `open_app` | Open App |
+| `show_content` | Show Content |
+| `show_app_list` | Show App List |
| `if` | If / Branch |
| `label` | Label |
---
-### `workflow` — one per home screen button per device
+### `workflow` — one per home screen button per device, or one per auto-synced installed app
| field | type | notes |
|---|---|---|
-| `name` | string | e.g. "Call Sandro" |
+| `name` | string | e.g. "Call Sandro", or the app's label for `workflow_type == "app"` |
| `homepage_position` | integer / null | 1-indexed position in the 2-column grid; null = not on home screen |
| `button_span` | integer | 1 = normal (1 cell), 2 = wide (full row); default 1 |
-| `is_active` | boolean | |
+| `workflow_type` | select | `custom` (default, caregiver-authored) or `app` (auto-synced from an installed app) |
+| `package_name` | string / null | set only for `workflow_type == "app"`; matched against on re-sync so a caregiver's added tutorial step or homepage_position always survives |
+| `is_active` | boolean | for `app` workflows: set to `false` on uninstall instead of deleting, so a reinstall reactivates the same row rather than duplicating it |
| `device` | belongsTo → devices | |
| `workflow_steps` | hasMany → workflow_step | fetched via `workflow_steps` append |
@@ -253,8 +256,7 @@ The caregiver web app dashboard groups these rows by device user → workflow
| sort_order | function_id | parameter_json |
|---|---|---|
-| 1 | `show_call_confirm` | — |
-| 2 | `call_contact` | `{"phone": "+41768224400"}` |
+| 1 | `call_contact` | `{"phone": "+41768224400"}` |
Steps run in order. No transitions, no if steps needed.
@@ -264,15 +266,14 @@ Steps run in order. No transitions, no if steps needed.
| sort_order | function_id | parameter_json | if_result_code | then_step | else_step |
|---|---|---|---|---|---|
-| 1 | `show_call_confirm` | — | — | — | — |
-| 2 | `show_speaker_choice` | — | — | — | — |
-| 3 | `if` | — | `speakerphone` | → step 4 | → step 5 |
-| 4 | `label` | — | — | — | — |
-| 5 | `call_contact` | `{"phone":"…","speakerphone":true}` | — | — | — |
-| 6 | `label` | — | — | — | — |
-| 7 | `call_contact` | `{"phone":"…","speakerphone":false}` | — | — | — |
+| 1 | `show_speaker_choice` | — | — | — | — |
+| 2 | `if` | — | `speakerphone` | → step 3 | → step 4 |
+| 3 | `label` | — | — | — | — |
+| 4 | `call_contact` | `{"phone":"…","speakerphone":true}` | — | — | — |
+| 5 | `label` | — | — | — | — |
+| 6 | `call_contact` | `{"phone":"…","speakerphone":false}` | — | — | — |
-Step 3 reads `lastResultCode` from step 2. If it equals `"speakerphone"`, jumps to step 4 (label) → step 5 (call with speaker). Otherwise jumps to step 6 (label) → step 7 (normal call).
+Step 2 reads `lastResultCode` from step 1. If it equals `"speakerphone"`, jumps to step 3 (label) → step 4 (call with speaker). Otherwise jumps to step 5 (label) → step 6 (normal call).
---
@@ -281,18 +282,25 @@ Step 3 reads `lastResultCode` from step 2. If it equals `"speakerphone"`, jumps
```
app/src/main/java/com/example/ichwillheim/
│
-├── MainActivity.kt — entry point, navigation state, workflow runner, call utilities
-├── HomeScreen.kt — patient home screen (clock, workflow buttons)
-├── ContentScreen.kt — data-driven content renderer for show_reminder / show_tutorial steps
-├── PreCallStepsScreen.kt — WorkflowConfirmScreen (show_call_confirm)
-├── SpeakerChoiceScreen.kt — WorkflowSpeakerChoiceScreen, SpeakerOptionHalf
-├── PinScreen.kt — PIN entry screen
-├── LauncherSettingsScreen.kt — SettingsMenuScreen, LauncherSettingsScreen, LanguagePicker
-├── CallOverlayService.kt — foreground service for call overlay
-├── WorkflowModels.kt — Workflow, WorkflowStep, ContentBlock
-├── WorkflowEngine.kt — WorkflowRunState: advance(), param(), start()
-├── SettingsRepository.kt — AppSettings + SharedPreferences
-└── RemoteConfigRepository.kt — Ktor HTTP client; fetches workflows from NocoBase
+├── MainActivity.kt — entry point, step dispatcher (the `when` block), call utilities
+├── ui/
+│ ├── HomeScreen.kt — patient home screen (clock, workflow buttons)
+│ ├── ContentScreen.kt — data-driven content renderer for show_content steps
+│ ├── SpeakerChoiceScreen.kt — WorkflowSpeakerChoiceScreen, SpeakerOptionHalf
+│ ├── PinScreen.kt — PIN entry screen
+│ ├── LauncherSettingsScreen.kt — SettingsMenuScreen, LauncherSettingsScreen, LanguagePicker
+│ └── theme/ — Color.kt, Theme.kt, Type.kt
+├── viewmodel/
+│ └── LauncherViewModel.kt — screen state + navigation flags, survives config changes
+├── domain/
+│ ├── WorkflowModels.kt — Workflow, WorkflowStep, ContentBlock
+│ └── WorkflowEngine.kt — WorkflowRunState: advance(), param(), start()
+├── data/
+│ ├── SettingsRepository.kt — AppSettings + SharedPreferences
+│ ├── RemoteConfigRepository.kt — Ktor HTTP client; fetches workflows from NocoBase
+│ └── InstalledApps.kt — LauncherApps enumeration of launchable apps
+└── service/
+ └── CallOverlayService.kt — foreground service for call overlay
```
---
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 54f07f6..f208909 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -58,6 +58,8 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4d6c734..04c51b6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,13 @@
+
+
+
+
+
+
+
@@ -39,9 +46,19 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/ichwillheim/HomeScreen.kt b/app/src/main/java/com/example/ichwillheim/HomeScreen.kt
deleted file mode 100644
index 441dc2d..0000000
--- a/app/src/main/java/com/example/ichwillheim/HomeScreen.kt
+++ /dev/null
@@ -1,196 +0,0 @@
-package com.example.ichwillheim
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material3.Button
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import kotlinx.coroutines.delay
-import java.util.Calendar
-import java.util.Locale
-
-private data class GridCell(
- val workflow: Workflow?,
- val isConsumed: Boolean = false // true when occupied by the second cell of a span-2 button
-)
-
-@Composable
-fun HomeScreen(
- onOpenSettings: () -> Unit,
- workflows: List = emptyList(),
- gridSize: Int = 6,
- onStartWorkflow: (Workflow) -> Unit = {},
- modifier: Modifier = Modifier
-) {
- val cols = 2
-
- // The caregiver may place buttons beyond the device's gridSize setting.
- // Instead of hiding them, auto-extend to the row that contains the highest button.
- val highestCell = workflows
- .filter { it.homepagePosition != null }
- .maxOfOrNull { w -> w.homepagePosition!! + (w.buttonSpan - 1) } ?: 0
- val rowsFromContent = if (highestCell > 0) (highestCell + 1) / 2 else 0
- val rows = maxOf(gridSize / cols, rowsFromContent)
- val effectiveGridSize = rows * cols
-
- // Build a flat array of cells.
- // span=2 means the button spans the full row (both columns).
- // We only allow span=2 when the button starts at column 0 (odd position in 1-indexed).
- val cells = Array(effectiveGridSize) { GridCell(null) }
- workflows
- .filter { it.homepagePosition != null && it.homepagePosition in 1..effectiveGridSize }
- .forEach { wf ->
- val pos = wf.homepagePosition!! - 1
- if (cells[pos].isConsumed) return@forEach
- val span = if (wf.buttonSpan == 2 && pos % cols == 0 && pos + 1 < effectiveGridSize) 2 else 1
- cells[pos] = GridCell(wf.copy(buttonSpan = span))
- if (span == 2) cells[pos + 1] = GridCell(null, isConsumed = true)
- }
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(horizontal = 16.dp, vertical = 12.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- // Clock: 1 share of vertical space
- ClockSection(modifier = Modifier.weight(1f).fillMaxWidth())
-
- // Grid: 3 shares — about 75% of the screen
- Column(
- modifier = Modifier.weight(3f).fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- for (row in 0 until rows) {
- val rowStart = row * cols
- val firstCell = cells[rowStart]
-
- Row(
- modifier = Modifier.weight(1f).fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- if (firstCell.workflow?.buttonSpan == 2) {
- // Span-2: button fills the entire row
- WorkflowButton(
- label = firstCell.workflow.name,
- onClick = { onStartWorkflow(firstCell.workflow) },
- modifier = Modifier.fillMaxWidth().fillMaxHeight()
- )
- } else {
- // Normal: two side-by-side cells
- for (col in 0 until cols) {
- val cell = cells[rowStart + col]
- when {
- cell.isConsumed -> { /* skip — covered by a span-2 button */ }
- cell.workflow != null ->
- WorkflowButton(
- label = cell.workflow.name,
- onClick = { onStartWorkflow(cell.workflow) },
- modifier = Modifier.weight(1f).fillMaxHeight()
- )
- else ->
- EmptyGridCell(modifier = Modifier.weight(1f).fillMaxHeight())
- }
- }
- }
- }
- }
- }
-
-
-
-
- IconButton(
- onClick = onOpenSettings,
- modifier = Modifier.padding(top = 4.dp).size(44.dp)
- ) {
- Icon(
- imageVector = Icons.Default.Settings,
- contentDescription = stringResource(R.string.cd_caregiver_settings)
- )
- }
- }
-}
-
-@Composable
-fun WorkflowButton(
- label: String,
- onClick: () -> Unit,
- modifier: Modifier = Modifier
-) {
- Button(
- onClick = onClick,
- shape = RoundedCornerShape(16.dp),
- modifier = modifier
- ) {
- Text(
- text = label,
- fontSize = 22.sp,
- fontWeight = FontWeight.Medium,
- textAlign = TextAlign.Center
- )
- }
-}
-
-@Composable
-private fun EmptyGridCell(modifier: Modifier = Modifier) {
- Box(modifier = modifier)
-}
-
-@Composable
-fun ClockSection(modifier: Modifier = Modifier) {
- var time by remember { mutableStateOf("") }
- var day by remember { mutableStateOf("") }
-
- LaunchedEffect(Unit) {
- while (true) {
- val calendar = Calendar.getInstance()
- val hour = calendar.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
- val minute = calendar.get(Calendar.MINUTE).toString().padStart(2, '0')
- time = "$hour:$minute"
- day = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: ""
- delay(1000)
- }
- }
-
- Column(
- modifier = modifier,
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(text = time, fontSize = 72.sp, fontWeight = FontWeight.Light)
- Text(
- text = day,
- fontSize = 28.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-}
diff --git a/app/src/main/java/com/example/ichwillheim/MainActivity.kt b/app/src/main/java/com/example/ichwillheim/MainActivity.kt
index 4ba6b60..9d9ce84 100644
--- a/app/src/main/java/com/example/ichwillheim/MainActivity.kt
+++ b/app/src/main/java/com/example/ichwillheim/MainActivity.kt
@@ -29,13 +29,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.setValue
-import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
@@ -43,7 +36,18 @@ import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ichwillheim.service.CallOverlayService
+import com.example.ichwillheim.service.EXTRA_SPEAKERPHONE
+import com.example.ichwillheim.ui.AppListScreen
+import com.example.ichwillheim.ui.ContentScreen
+import com.example.ichwillheim.ui.HomeScreen
+import com.example.ichwillheim.ui.LauncherSettingsScreen
+import com.example.ichwillheim.ui.PinScreen
+import com.example.ichwillheim.ui.SettingsMenuScreen
+import com.example.ichwillheim.ui.WorkflowSpeakerChoiceScreen
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
+import com.example.ichwillheim.viewmodel.LauncherViewModel
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -60,11 +64,8 @@ class MainActivity : AppCompatActivity() {
}
@Composable
-fun LauncherScreen(modifier: Modifier = Modifier) {
+fun LauncherScreen(modifier: Modifier = Modifier, viewModel: LauncherViewModel = viewModel()) {
val context = LocalContext.current
- val scope = rememberCoroutineScope()
-
- var settings by remember { mutableStateOf(SettingsRepository.load(context)) }
// Request CALL_PHONE permission at startup if not already granted.
// CALL_PHONE is a "dangerous" permission — having it in the manifest is not enough on
@@ -80,197 +81,107 @@ fun LauncherScreen(modifier: Modifier = Modifier) {
}
}
- // Navigation flags — only one is true at a time.
- var showPin by remember { mutableStateOf(false) }
- var showCaregiverMenu by remember { mutableStateOf(false) }
- var showLauncherSettings by remember { mutableStateOf(false) }
- var activeRun by remember { mutableStateOf(null) }
- var stepStartTime by remember { mutableStateOf(System.currentTimeMillis()) }
- LaunchedEffect(activeRun?.currentStep?.id) { stepStartTime = System.currentTimeMillis() }
-
// Fetch remote config on every resume so the home screen stays up-to-date when
// the caregiver adds or changes workflows without restarting the app.
// Also resets sub-screen navigation so the patient always returns to the home screen.
- val settingsRef = rememberUpdatedState(settings)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME) {
- showPin = false
- showCaregiverMenu = false
- showLauncherSettings = false
- activeRun = null
- if (settingsRef.value.deviceId.isNotBlank()) {
- scope.launch {
- val remote = RemoteConfigRepository.fetchWorkflows(settingsRef.value.deviceId)
- if (remote != null) {
- val updated = settingsRef.value.copy(workflows = remote)
- SettingsRepository.save(context, updated)
- settings = updated
- }
- }
- }
- }
+ if (event == Lifecycle.Event.ON_RESUME) viewModel.onResume()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
- val anySubScreenOpen = showPin || showCaregiverMenu || showLauncherSettings || activeRun != null
+ val activeRun = viewModel.activeRun
+ val anySubScreenOpen = viewModel.showPin || viewModel.showCaregiverMenu ||
+ viewModel.showLauncherSettings || activeRun != null
BackHandler(enabled = !anySubScreenOpen) { /* swallow back on main screen */ }
if (activeRun != null) {
- val run = activeRun!!
- android.util.Log.d("WorkflowEngine", "Step: ${run.currentStep?.functionKey}, finished: ${run.isFinished}")
- when (run.currentStep?.functionKey) {
- "show_call_confirm" -> WorkflowConfirmScreen(
- title = run.workflow.name,
- onConfirm = {
- val completedStep = run.currentStep
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- val next = run.advance("confirmed")
- activeRun = next.takeIf { !next.isFinished }
- if (completedStep != null && settings.deviceId.isNotBlank()) {
- scope.launch {
- RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "confirmed", duration)
- }
- }
- },
- onCancel = {
- val completedStep = run.currentStep
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- activeRun = null
- if (completedStep != null && settings.deviceId.isNotBlank()) {
- scope.launch {
- RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "cancelled", duration)
- }
- }
- }
- )
+ android.util.Log.d("WorkflowEngine", "Step: ${activeRun.currentStep?.functionKey}, finished: ${activeRun.isFinished}")
+ when (activeRun.currentStep?.functionKey) {
"show_speaker_choice" -> WorkflowSpeakerChoiceScreen(
- onChoice = { choice ->
- val completedStep = run.currentStep
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- val next = run.advance(choice)
- activeRun = next.takeIf { !next.isFinished }
- if (completedStep != null && settings.deviceId.isNotBlank()) {
- scope.launch {
- RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, choice, duration)
- }
- }
- },
- onCancel = {
- val completedStep = run.currentStep
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- activeRun = null
- if (completedStep != null && settings.deviceId.isNotBlank()) {
- scope.launch {
- RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "cancelled", duration)
- }
- }
- }
+ onChoice = { choice -> viewModel.advance(choice) },
+ onCancel = { viewModel.cancelActiveRun() }
)
- "call_contact" -> LaunchedEffect(run.currentStep!!.id) {
- val completedStep = run.currentStep!!
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- val phone = run.param("phone") ?: ""
- val speakerphone = run.param("speakerphone")?.toBooleanStrictOrNull()
- ?: (run.lastResultCode == "speakerphone")
+ "call_contact" -> LaunchedEffect(activeRun.currentStep!!.id) {
+ val phone = activeRun.param("phone") ?: ""
+ val speakerphone = activeRun.param("speakerphone")?.toBooleanStrictOrNull()
+ ?: (activeRun.lastResultCode == "speakerphone")
android.util.Log.d("WorkflowEngine", "call_contact: phone='$phone' speakerphone=$speakerphone")
if (phone.isBlank()) {
Toast.makeText(context, "No phone number set for this step. Add a contact in the web app.", Toast.LENGTH_LONG).show()
} else {
performCall(context, phone, speakerphone, showOverlay = false)
}
- val next = run.advance(null)
- activeRun = next.takeIf { !next.isFinished }
- if (settings.deviceId.isNotBlank()) {
- RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, null, duration)
+ viewModel.advance(null)
+ }
+ "open_app" -> LaunchedEffect(activeRun.currentStep!!.id) {
+ val packageName = activeRun.param("package_name") ?: ""
+ val launchIntent = packageName.takeIf { it.isNotBlank() }
+ ?.let { context.packageManager.getLaunchIntentForPackage(it) }
+ if (launchIntent != null) {
+ context.startActivity(launchIntent)
+ } else {
+ Toast.makeText(context, "App not found on this device.", Toast.LENGTH_LONG).show()
}
+ viewModel.advance(null)
}
"show_content" -> ContentScreen(
- blocks = run.currentStep?.contentBlocks ?: emptyList(),
- onAdvance = { resultCode ->
- val completedStep = run.currentStep
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- val next = run.advance(resultCode)
- activeRun = next.takeIf { !next.isFinished }
- if (completedStep != null && settings.deviceId.isNotBlank()) {
- scope.launch {
- RemoteConfigRepository.logEvent(
- settings.deviceId, run.workflow.name,
- completedStep.functionKey, resultCode, duration
- )
- }
- }
- },
- onCancel = {
- val completedStep = run.currentStep
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- activeRun = null
- if (completedStep != null && settings.deviceId.isNotBlank()) {
- scope.launch {
- RemoteConfigRepository.logEvent(
- settings.deviceId, run.workflow.name,
- completedStep.functionKey, "cancelled", duration
- )
- }
- }
- }
+ blocks = activeRun.currentStep?.contentBlocks ?: emptyList(),
+ onAdvance = { resultCode -> viewModel.advance(resultCode) },
+ onCancel = { viewModel.cancelActiveRun() }
)
- else -> LaunchedEffect(run.currentStep?.id) {
- val completedStep = run.currentStep
- val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
- val next = run.advance(null)
- activeRun = next.takeIf { !next.isFinished }
- if (completedStep != null && settings.deviceId.isNotBlank()) {
- RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, null, duration)
- }
+ "show_app_list" -> AppListScreen(
+ workflows = viewModel.settings.workflows,
+ onStartWorkflow = { workflow -> viewModel.startWorkflow(workflow) },
+ onCancel = { viewModel.cancelActiveRun() }
+ )
+ else -> LaunchedEffect(activeRun.currentStep?.id) {
+ viewModel.advance(null)
}
}
return
}
- if (showLauncherSettings) {
+ if (viewModel.showLauncherSettings) {
LauncherSettingsScreen(
- settings = settings,
- onSettingsChanged = { updated ->
- settings = updated
- SettingsRepository.save(context, updated)
- },
- onClose = { showLauncherSettings = false }
+ settings = viewModel.settings,
+ onSettingsChanged = { updated -> viewModel.updateSettings(updated) },
+ onClose = { viewModel.closeLauncherSettings() },
+ isSyncingApps = viewModel.isSyncingApps,
+ appSyncResultCount = viewModel.appSyncResultCount,
+ onSyncApps = { viewModel.syncInstalledApps() }
)
return
}
- if (showCaregiverMenu) {
+ if (viewModel.showCaregiverMenu) {
SettingsMenuScreen(
- onLauncherSettings = { showLauncherSettings = true },
+ onLauncherSettings = { viewModel.openLauncherSettings() },
onPhoneSettings = {
- showCaregiverMenu = false
+ viewModel.closeCaregiverMenu()
context.startActivity(Intent(Settings.ACTION_SETTINGS))
},
- onClose = { showCaregiverMenu = false }
+ onClose = { viewModel.closeCaregiverMenu() }
)
return
}
- if (showPin) {
+ if (viewModel.showPin) {
PinScreen(
- onCorrectPin = {
- showPin = false
- showCaregiverMenu = true
- },
- onCancel = { showPin = false }
+ onCorrectPin = { viewModel.confirmPin() },
+ onCancel = { viewModel.cancelPin() }
)
return
}
HomeScreen(
- onOpenSettings = { showPin = true },
- workflows = settings.workflows,
- gridSize = settings.gridSize,
- onStartWorkflow = { workflow -> activeRun = WorkflowRunState.start(workflow).takeIf { !it.isFinished } },
+ onOpenSettings = { viewModel.openPin() },
+ workflows = viewModel.settings.workflows,
+ gridSize = viewModel.settings.gridSize,
+ onStartWorkflow = { workflow -> viewModel.startWorkflow(workflow) },
modifier = modifier
)
}
diff --git a/app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt b/app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt
deleted file mode 100644
index e08bb98..0000000
--- a/app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.example.ichwillheim
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-
-@Composable
-fun WorkflowConfirmScreen(
- title: String,
- onConfirm: () -> Unit,
- onCancel: () -> Unit
-) {
- BackHandler { onCancel() }
- Column(
- modifier = Modifier.fillMaxSize().padding(24.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = title,
- style = MaterialTheme.typography.headlineSmall,
- textAlign = TextAlign.Center
- )
- Spacer(modifier = Modifier.height(32.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
- Button(
- onClick = onCancel,
- colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
- modifier = Modifier.weight(1f).height(72.dp)
- ) { Text(stringResource(R.string.action_cancel), fontSize = 18.sp) }
- Button(
- onClick = onConfirm,
- modifier = Modifier.weight(1f).height(72.dp)
- ) { Text(stringResource(R.string.action_call), fontSize = 18.sp) }
- }
- }
-}
diff --git a/app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt b/app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt
deleted file mode 100644
index 8d94823..0000000
--- a/app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt
+++ /dev/null
@@ -1,188 +0,0 @@
-package com.example.ichwillheim
-
-import android.util.Log
-import io.ktor.client.HttpClient
-import io.ktor.client.statement.bodyAsText
-import io.ktor.client.engine.android.Android
-import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.client.request.get
-import io.ktor.client.request.header
-import io.ktor.client.request.post
-import io.ktor.client.request.setBody
-import io.ktor.http.ContentType
-import io.ktor.http.contentType
-import io.ktor.serialization.kotlinx.json.json
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-
-// --- DTOs ---
-
-@Serializable
-private data class EventLogPayload(
- @SerialName("device_id") val deviceId: String,
- @SerialName("workflow_name") val workflowName: String,
- @SerialName("function_id") val functionId: String,
- @SerialName("result_code") val resultCode: String? = null,
- @SerialName("duration_seconds") val durationSeconds: Double
-)
-
-// --- NocoBase response DTOs ---
-
-@Serializable
-private data class NbListResponse(val data: List)
-
-@Serializable
-private data class NbWorkflow(
- val id: Long,
- val name: String,
- val icon: String? = null,
- @SerialName("homepage_position") val homepagePosition: Int? = null,
- @SerialName("button_span") val buttonSpan: Int? = null,
- @SerialName("workflow_steps") val steps: List = emptyList()
-)
-
-@Serializable
-private data class NbStep(
- val id: Long,
- val name: String,
- val function: NbFunction? = null,
- @SerialName("sort_order") val sortOrder: Int? = null,
- @SerialName("parameter_json") val parameterJson: String? = null,
- @SerialName("if_result_code") val ifResultCode: String? = null,
- @SerialName("then_step_key") val thenStepKey: Long? = null,
- @SerialName("else_step_key") val elseStepKey: Long? = null,
- @SerialName("content_block") val contentBlocks: List = emptyList()
-)
-
-@Serializable
-private data class NbFunction(
- val id: Long,
- @SerialName("function_id") val functionKey: String
-)
-
-@Serializable
-private data class NbContentBlock(
- val id: Long,
- val type: String,
- val value: String,
- @SerialName("sort_order") val sortOrder: Int? = null,
- @SerialName("result_code") val resultCode: String? = null,
-)
-
-@Serializable
-private data class NbRef(val id: Long)
-
-@Serializable
-private data class NbDeviceRef(val id: Long)
-
-// --- Mapping from DTOs to domain models ---
-
-private fun NbWorkflow.toDomain() = Workflow(
- id = id,
- name = name,
- icon = icon,
- homepagePosition = homepagePosition,
- buttonSpan = buttonSpan ?: 1,
- steps = steps.map { it.toDomain() },
-)
-
-private fun NbStep.toDomain() = WorkflowStep(
- id = id,
- name = name,
- functionKey = function?.functionKey ?: "",
- sortOrder = sortOrder ?: Int.MAX_VALUE,
- parameterJson = parameterJson,
- ifResultCode = ifResultCode,
- thenStepId = thenStepKey,
- elseStepId = elseStepKey,
- contentBlocks = contentBlocks.sortedBy { it.sortOrder ?: 0 }.map { it.toDomain() }
-)
-
-private fun NbContentBlock.toDomain() = ContentBlock(
- id = id,
- type = type,
- value = value,
- position = sortOrder ?: 0,
- resultCode = resultCode,
-)
-
-// --- Repository ---
-
-object RemoteConfigRepository {
-
- private val client = HttpClient(Android) {
- install(ContentNegotiation) {
- json(Json { ignoreUnknownKeys = true })
- }
- }
-
- suspend fun fetchWorkflows(deviceId: String): List? {
- return try {
- Log.d("RemoteConfig", "Fetching workflows for device: $deviceId")
- val devicePk = resolveDevicePk(deviceId) ?: run {
- Log.e("RemoteConfig", "Device not found: $deviceId")
- return null
- }
- val raw = client.get(
- "${BuildConfig.NOCOBASE_URL}/api/workflow:list"
- ) {
- header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
- url {
- parameters.append("filter", """{"device_key":"$devicePk"}""")
- parameters.appendAll(
- "appends[]", listOf(
- "workflow_steps",
- "workflow_steps.function",
- "workflow_steps.content_block"
- )
- )
- }
- }.bodyAsText()
- Log.d("RemoteConfig", "Raw response: $raw")
- val response = Json { ignoreUnknownKeys = true }
- .decodeFromString>(raw)
- val workflows = response.data.map { it.toDomain() }
- Log.d("RemoteConfig", "Fetched ${workflows.size} workflow(s)")
- workflows
- } catch (e: Exception) {
- Log.e("RemoteConfig", "Failed to fetch workflows: ${e.message}")
- null
- }
- }
-
- suspend fun logEvent(
- deviceId: String,
- workflowName: String,
- functionId: String,
- resultCode: String?,
- durationSeconds: Double
- ) {
- try {
- client.post("${BuildConfig.NOCOBASE_URL}/api/event_log:create") {
- header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
- contentType(ContentType.Application.Json)
- setBody(EventLogPayload(deviceId, workflowName, functionId, resultCode, durationSeconds))
- }
- } catch (e: Exception) {
- Log.e("RemoteConfig", "Failed to log event: ${e.message}")
- }
- }
-
- private suspend fun resolveDevicePk(deviceId: String): Long? {
- return try {
- val raw = client.get(
- "${BuildConfig.NOCOBASE_URL}/api/devices:list"
- ) {
- header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
- url { parameters.append("filter", """{"device_id":"$deviceId"}""") }
- }.bodyAsText()
- Json { ignoreUnknownKeys = true }
- .decodeFromString>(raw)
- .data.firstOrNull()?.id
- } catch (e: Exception) {
- Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}")
- null
- }
- }
-}
diff --git a/app/src/main/java/com/example/ichwillheim/data/RemoteConfigRepository.kt b/app/src/main/java/com/example/ichwillheim/data/RemoteConfigRepository.kt
new file mode 100644
index 0000000..3e5b7da
--- /dev/null
+++ b/app/src/main/java/com/example/ichwillheim/data/RemoteConfigRepository.kt
@@ -0,0 +1,412 @@
+package com.example.ichwillheim.data
+
+import android.util.Log
+import com.example.ichwillheim.BuildConfig
+import com.example.ichwillheim.domain.ContentBlock
+import com.example.ichwillheim.domain.Workflow
+import com.example.ichwillheim.domain.WorkflowStep
+import io.ktor.client.HttpClient
+import io.ktor.client.statement.bodyAsText
+import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.request.get
+import io.ktor.client.request.header
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+// --- DTOs ---
+
+@Serializable
+private data class EventLogPayload(
+ @SerialName("device_id") val deviceId: String,
+ @SerialName("workflow_name") val workflowName: String,
+ @SerialName("function_id") val functionId: String,
+ @SerialName("result_code") val resultCode: String? = null,
+ @SerialName("duration_seconds") val durationSeconds: Double
+)
+
+// --- NocoBase response DTOs ---
+
+@Serializable
+private data class NbListResponse(val data: List)
+
+@Serializable
+private data class NbWorkflow(
+ val id: Long,
+ val name: String,
+ val icon: String? = null,
+ @SerialName("homepage_position") val homepagePosition: Int? = null,
+ @SerialName("button_span") val buttonSpan: Int? = null,
+ @SerialName("workflow_type") val workflowType: String? = null,
+ @SerialName("package_name") val packageName: String? = null,
+ @SerialName("workflow_steps") val steps: List = emptyList()
+)
+
+@Serializable
+private data class NbStep(
+ val id: Long,
+ val name: String,
+ val function: NbFunction? = null,
+ @SerialName("sort_order") val sortOrder: Int? = null,
+ @SerialName("parameter_json") val parameterJson: String? = null,
+ @SerialName("if_result_code") val ifResultCode: String? = null,
+ @SerialName("then_step_key") val thenStepKey: Long? = null,
+ @SerialName("else_step_key") val elseStepKey: Long? = null,
+ @SerialName("content_block") val contentBlocks: List = emptyList()
+)
+
+@Serializable
+private data class NbFunction(
+ val id: Long,
+ @SerialName("function_id") val functionKey: String
+)
+
+@Serializable
+private data class NbContentBlock(
+ val id: Long,
+ val type: String,
+ val value: String,
+ @SerialName("sort_order") val sortOrder: Int? = null,
+ @SerialName("result_code") val resultCode: String? = null,
+)
+
+@Serializable
+private data class NbRef(val id: Long)
+
+@Serializable
+private data class NbDeviceRef(val id: Long)
+
+@Serializable
+private data class NbSingleResponse(val data: T)
+
+@Serializable
+private data class NbWorkflowNameRef(
+ val id: Long,
+ val name: String,
+ @SerialName("is_active") val isActive: Boolean? = true
+)
+
+// --- App workflow sync DTOs ---
+
+@Serializable
+private data class WorkflowCreatePayload(
+ val name: String,
+ @SerialName("workflow_type") val workflowType: String,
+ @SerialName("package_name") val packageName: String,
+ @SerialName("device_key") val deviceKey: Long,
+)
+
+@Serializable
+private data class WorkflowUpdatePayload(val name: String)
+
+@Serializable
+private data class WorkflowActiveUpdatePayload(@SerialName("is_active") val isActive: Boolean)
+
+@Serializable
+private data class StepCreatePayload(
+ val name: String,
+ @SerialName("sort_order") val sortOrder: Int,
+ @SerialName("parameter_json") val parameterJson: String? = null,
+ @SerialName("function_key") val functionKey: Long,
+ @SerialName("workflow_key") val workflowKey: Long,
+)
+
+@Serializable
+private data class ContentBlockCreatePayload(
+ val type: String,
+ val value: String,
+ @SerialName("sort_order") val sortOrder: Int,
+ @SerialName("result_code") val resultCode: String? = null,
+ @SerialName("content_block_key") val contentBlockKey: Long,
+)
+
+// --- Mapping from DTOs to domain models ---
+
+private fun NbWorkflow.toDomain() = Workflow(
+ id = id,
+ name = name,
+ icon = icon,
+ homepagePosition = homepagePosition,
+ buttonSpan = buttonSpan ?: 1,
+ workflowType = workflowType ?: "custom",
+ packageName = packageName,
+ steps = steps.map { it.toDomain() },
+)
+
+private fun NbStep.toDomain() = WorkflowStep(
+ id = id,
+ name = name,
+ functionKey = function?.functionKey ?: "",
+ sortOrder = sortOrder ?: Int.MAX_VALUE,
+ parameterJson = parameterJson,
+ ifResultCode = ifResultCode,
+ thenStepId = thenStepKey,
+ elseStepId = elseStepKey,
+ contentBlocks = contentBlocks.sortedBy { it.sortOrder ?: 0 }.map { it.toDomain() }
+)
+
+private fun NbContentBlock.toDomain() = ContentBlock(
+ id = id,
+ type = type,
+ value = value,
+ position = sortOrder ?: 0,
+ resultCode = resultCode,
+)
+
+// --- Repository ---
+
+object RemoteConfigRepository {
+
+ private val client = HttpClient(Android) {
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ }
+
+ suspend fun fetchWorkflows(deviceId: String): List? {
+ return try {
+ Log.d("RemoteConfig", "Fetching workflows for device: $deviceId")
+ val devicePk = resolveDevicePk(deviceId) ?: run {
+ Log.e("RemoteConfig", "Device not found: $deviceId")
+ return null
+ }
+ val raw = client.get(
+ "${BuildConfig.NOCOBASE_URL}/api/workflow:list"
+ ) {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ url {
+ parameters.append("filter", """{"device_key":"$devicePk"}""")
+ parameters.append("paginate","false")
+ parameters.appendAll(
+ "appends[]", listOf(
+ "workflow_steps",
+ "workflow_steps.function",
+ "workflow_steps.content_block"
+ )
+ )
+ }
+ }.bodyAsText()
+ Log.d("RemoteConfig", "Raw response: $raw")
+ val response = Json { ignoreUnknownKeys = true }
+ .decodeFromString>(raw)
+ val workflows = response.data.map { it.toDomain() }
+ Log.d("RemoteConfig", "Fetched ${workflows.size} workflow(s)")
+ workflows
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to fetch workflows: ${e.message}")
+ null
+ }
+ }
+
+ suspend fun logEvent(
+ deviceId: String,
+ workflowName: String,
+ functionId: String,
+ resultCode: String?,
+ durationSeconds: Double
+ ) {
+ try {
+ client.post("${BuildConfig.NOCOBASE_URL}/api/event_log:create") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ contentType(ContentType.Application.Json)
+ setBody(EventLogPayload(deviceId, workflowName, functionId, resultCode, durationSeconds))
+ }
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to log event: ${e.message}")
+ }
+ }
+
+ // Creates a `workflow_type=app` workflow for one installed app if none exists yet for
+ // this device+package. Never touches an existing row's steps — a caregiver's added
+ // tutorial step or homepage_position survives every re-sync. Only the name is
+ // refreshed if the app's label changed (e.g. after an app update).
+ suspend fun upsertAppWorkflow(deviceId: String, packageName: String, label: String): Boolean {
+ return try {
+ val devicePk = resolveDevicePk(deviceId) ?: return false
+ val existing = findExistingAppWorkflow(devicePk, packageName)
+ if (existing != null) {
+ if (existing.name != label) updateWorkflowName(existing.id, label)
+ if (existing.isActive == false) updateWorkflowActive(existing.id, isActive = true)
+ return true
+ }
+
+ val workflowId = createWorkflow(
+ WorkflowCreatePayload(
+ name = label, workflowType = "app", packageName = packageName, deviceKey = devicePk
+ )
+ ) ?: return false
+
+ var sortOrder = 1
+ AppTemplates.presets[packageName]?.let { blocks ->
+ val contentFunctionPk = resolveFunctionPk("show_content")
+ val stepId = contentFunctionPk?.let {
+ createStep(
+ StepCreatePayload(
+ name = "Intro", sortOrder = sortOrder, functionKey = it, workflowKey = workflowId
+ )
+ )
+ }
+ if (stepId != null) {
+ sortOrder++
+ blocks.forEachIndexed { index, block ->
+ createContentBlock(
+ ContentBlockCreatePayload(
+ type = block.type,
+ value = block.value,
+ sortOrder = index,
+ resultCode = block.resultCode,
+ contentBlockKey = stepId
+ )
+ )
+ }
+ }
+ }
+
+ val openAppFunctionPk = resolveFunctionPk("open_app") ?: return false
+ createStep(
+ StepCreatePayload(
+ name = label,
+ sortOrder = sortOrder,
+ parameterJson = """{"package_name":"$packageName"}""",
+ functionKey = openAppFunctionPk,
+ workflowKey = workflowId
+ )
+ )
+ true
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to upsert app workflow for $packageName: ${e.message}")
+ false
+ }
+ }
+
+ // Marks an app's workflow inactive on uninstall rather than deleting it, so a
+ // caregiver's tutorial edits survive a later reinstall (upsertAppWorkflow
+ // reactivates it instead of creating a duplicate).
+ suspend fun deactivateAppWorkflow(deviceId: String, packageName: String): Boolean {
+ return try {
+ val devicePk = resolveDevicePk(deviceId) ?: return false
+ val existing = findExistingAppWorkflow(devicePk, packageName) ?: return true
+ updateWorkflowActive(existing.id, isActive = false)
+ true
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to deactivate app workflow for $packageName: ${e.message}")
+ false
+ }
+ }
+
+ private suspend fun findExistingAppWorkflow(devicePk: Long, packageName: String): NbWorkflowNameRef? {
+ return try {
+ val raw = client.get("${BuildConfig.NOCOBASE_URL}/api/workflow:list") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ url { parameters.append("filter", """{"device_key":"$devicePk","package_name":"$packageName"}""") }
+ }.bodyAsText()
+ Json { ignoreUnknownKeys = true }
+ .decodeFromString>(raw).data.firstOrNull()
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to check existing app workflow for $packageName: ${e.message}")
+ null
+ }
+ }
+
+ private suspend fun createWorkflow(payload: WorkflowCreatePayload): Long? {
+ return try {
+ val raw = client.post("${BuildConfig.NOCOBASE_URL}/api/workflow:create") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ contentType(ContentType.Application.Json)
+ setBody(payload)
+ }.bodyAsText()
+ Json { ignoreUnknownKeys = true }.decodeFromString>(raw).data.id
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to create workflow for ${payload.packageName}: ${e.message}")
+ null
+ }
+ }
+
+ private suspend fun createStep(payload: StepCreatePayload): Long? {
+ return try {
+ val raw = client.post("${BuildConfig.NOCOBASE_URL}/api/workflow_step:create") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ contentType(ContentType.Application.Json)
+ setBody(payload)
+ }.bodyAsText()
+ Json { ignoreUnknownKeys = true }.decodeFromString>(raw).data.id
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to create workflow_step: ${e.message}")
+ null
+ }
+ }
+
+ private suspend fun createContentBlock(payload: ContentBlockCreatePayload) {
+ try {
+ client.post("${BuildConfig.NOCOBASE_URL}/api/content_block:create") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ contentType(ContentType.Application.Json)
+ setBody(payload)
+ }
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to create content_block: ${e.message}")
+ }
+ }
+
+ private suspend fun updateWorkflowName(workflowId: Long, name: String) {
+ try {
+ client.post("${BuildConfig.NOCOBASE_URL}/api/workflow:update") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ contentType(ContentType.Application.Json)
+ url { parameters.append("filterByTk", workflowId.toString()) }
+ setBody(WorkflowUpdatePayload(name))
+ }
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to update workflow name: ${e.message}")
+ }
+ }
+
+ private suspend fun updateWorkflowActive(workflowId: Long, isActive: Boolean) {
+ try {
+ client.post("${BuildConfig.NOCOBASE_URL}/api/workflow:update") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ contentType(ContentType.Application.Json)
+ url { parameters.append("filterByTk", workflowId.toString()) }
+ setBody(WorkflowActiveUpdatePayload(isActive))
+ }
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to update workflow active state: ${e.message}")
+ }
+ }
+
+ private suspend fun resolveFunctionPk(functionId: String): Long? {
+ return try {
+ val raw = client.get("${BuildConfig.NOCOBASE_URL}/api/workflow_function:list") {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ url { parameters.append("filter", """{"function_id":"$functionId"}""") }
+ }.bodyAsText()
+ Json { ignoreUnknownKeys = true }.decodeFromString>(raw).data.firstOrNull()?.id
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to resolve function pk for $functionId: ${e.message}")
+ null
+ }
+ }
+
+ private suspend fun resolveDevicePk(deviceId: String): Long? {
+ return try {
+ val raw = client.get(
+ "${BuildConfig.NOCOBASE_URL}/api/devices:list"
+ ) {
+ header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
+ url { parameters.append("filter", """{"device_id":"$deviceId"}""") }
+ }.bodyAsText()
+ Json { ignoreUnknownKeys = true }
+ .decodeFromString>(raw)
+ .data.firstOrNull()?.id
+ } catch (e: Exception) {
+ Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}")
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ichwillheim/SettingsRepository.kt b/app/src/main/java/com/example/ichwillheim/data/SettingsRepository.kt
similarity index 92%
rename from app/src/main/java/com/example/ichwillheim/SettingsRepository.kt
rename to app/src/main/java/com/example/ichwillheim/data/SettingsRepository.kt
index fce75ad..3bfcc61 100644
--- a/app/src/main/java/com/example/ichwillheim/SettingsRepository.kt
+++ b/app/src/main/java/com/example/ichwillheim/data/SettingsRepository.kt
@@ -1,6 +1,7 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.data
import android.content.Context
+import com.example.ichwillheim.domain.Workflow
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
diff --git a/app/src/main/java/com/example/ichwillheim/WorkflowEngine.kt b/app/src/main/java/com/example/ichwillheim/domain/WorkflowEngine.kt
similarity index 97%
rename from app/src/main/java/com/example/ichwillheim/WorkflowEngine.kt
rename to app/src/main/java/com/example/ichwillheim/domain/WorkflowEngine.kt
index 8a0ed5f..3e34f4c 100644
--- a/app/src/main/java/com/example/ichwillheim/WorkflowEngine.kt
+++ b/app/src/main/java/com/example/ichwillheim/domain/WorkflowEngine.kt
@@ -1,4 +1,4 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.domain
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
diff --git a/app/src/main/java/com/example/ichwillheim/WorkflowModels.kt b/app/src/main/java/com/example/ichwillheim/domain/WorkflowModels.kt
similarity index 78%
rename from app/src/main/java/com/example/ichwillheim/WorkflowModels.kt
rename to app/src/main/java/com/example/ichwillheim/domain/WorkflowModels.kt
index 317c789..32b46a2 100644
--- a/app/src/main/java/com/example/ichwillheim/WorkflowModels.kt
+++ b/app/src/main/java/com/example/ichwillheim/domain/WorkflowModels.kt
@@ -1,4 +1,4 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.domain
import kotlinx.serialization.Serializable
@@ -31,5 +31,7 @@ data class Workflow(
val icon: String? = null,
val homepagePosition: Int? = null,
val buttonSpan: Int = 1, // 1 = one cell, 2 = full row (both columns)
+ val workflowType: String = "custom", // "custom" (caregiver-authored) or "app" (auto-synced from an installed app)
+ val packageName: String? = null, // set only for workflowType == "app"
val steps: List = emptyList(),
)
diff --git a/app/src/main/java/com/example/ichwillheim/CallOverlayService.kt b/app/src/main/java/com/example/ichwillheim/service/CallOverlayService.kt
similarity index 99%
rename from app/src/main/java/com/example/ichwillheim/CallOverlayService.kt
rename to app/src/main/java/com/example/ichwillheim/service/CallOverlayService.kt
index 7a09301..9be9b3c 100644
--- a/app/src/main/java/com/example/ichwillheim/CallOverlayService.kt
+++ b/app/src/main/java/com/example/ichwillheim/service/CallOverlayService.kt
@@ -1,4 +1,4 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.service
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -56,6 +56,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.example.ichwillheim.R
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
const val EXTRA_SPEAKERPHONE = "speakerphone"
diff --git a/app/src/main/java/com/example/ichwillheim/ContentScreen.kt b/app/src/main/java/com/example/ichwillheim/ui/ContentScreen.kt
similarity index 97%
rename from app/src/main/java/com/example/ichwillheim/ContentScreen.kt
rename to app/src/main/java/com/example/ichwillheim/ui/ContentScreen.kt
index d767af6..44edd4c 100644
--- a/app/src/main/java/com/example/ichwillheim/ContentScreen.kt
+++ b/app/src/main/java/com/example/ichwillheim/ui/ContentScreen.kt
@@ -1,4 +1,4 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
@@ -25,6 +25,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.example.ichwillheim.R
+import com.example.ichwillheim.domain.ContentBlock
/**
* Generic content screen for data-driven workflow steps.
diff --git a/app/src/main/java/com/example/ichwillheim/ui/HomeScreen.kt b/app/src/main/java/com/example/ichwillheim/ui/HomeScreen.kt
new file mode 100644
index 0000000..1f021cc
--- /dev/null
+++ b/app/src/main/java/com/example/ichwillheim/ui/HomeScreen.kt
@@ -0,0 +1,98 @@
+package com.example.ichwillheim.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.ichwillheim.R
+import com.example.ichwillheim.domain.Workflow
+import kotlinx.coroutines.delay
+import java.util.Calendar
+import java.util.Locale
+
+@Composable
+fun HomeScreen(
+ onOpenSettings: () -> Unit,
+ workflows: List = emptyList(),
+ gridSize: Int = 6,
+ onStartWorkflow: (Workflow) -> Unit = {},
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Clock: 1 share of vertical space
+ ClockSection(modifier = Modifier.weight(1f).fillMaxWidth())
+
+ // Grid: 3 shares — about 75% of the screen
+ WorkflowGrid(
+ workflows = workflows,
+ gridSize = gridSize,
+ onStartWorkflow = onStartWorkflow,
+ modifier = Modifier.weight(3f).fillMaxWidth()
+ )
+
+ IconButton(
+ onClick = onOpenSettings,
+ modifier = Modifier.padding(top = 4.dp).size(44.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = stringResource(R.string.cd_caregiver_settings)
+ )
+ }
+ }
+}
+
+@Composable
+fun ClockSection(modifier: Modifier = Modifier) {
+ var time by remember { mutableStateOf("") }
+ var day by remember { mutableStateOf("") }
+
+ LaunchedEffect(Unit) {
+ while (true) {
+ val calendar = Calendar.getInstance()
+ val hour = calendar.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
+ val minute = calendar.get(Calendar.MINUTE).toString().padStart(2, '0')
+ time = "$hour:$minute"
+ day = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: ""
+ delay(1000)
+ }
+ }
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = time, fontSize = 72.sp, fontWeight = FontWeight.Light)
+ Text(
+ text = day,
+ fontSize = 28.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/ichwillheim/LauncherSettingsScreen.kt b/app/src/main/java/com/example/ichwillheim/ui/LauncherSettingsScreen.kt
similarity index 83%
rename from app/src/main/java/com/example/ichwillheim/LauncherSettingsScreen.kt
rename to app/src/main/java/com/example/ichwillheim/ui/LauncherSettingsScreen.kt
index 358d20a..38be8c8 100644
--- a/app/src/main/java/com/example/ichwillheim/LauncherSettingsScreen.kt
+++ b/app/src/main/java/com/example/ichwillheim/ui/LauncherSettingsScreen.kt
@@ -1,4 +1,4 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.ui
import androidx.activity.compose.BackHandler
import androidx.appcompat.app.AppCompatDelegate
@@ -25,6 +25,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
+import com.example.ichwillheim.R
+import com.example.ichwillheim.data.AppSettings
@Composable
fun SettingsMenuScreen(
@@ -69,7 +71,10 @@ fun SettingsMenuScreen(
fun LauncherSettingsScreen(
settings: AppSettings,
onSettingsChanged: (AppSettings) -> Unit,
- onClose: () -> Unit
+ onClose: () -> Unit,
+ isSyncingApps: Boolean = false,
+ appSyncResultCount: Int? = null,
+ onSyncApps: () -> Unit = {}
) {
BackHandler { onClose() }
@@ -101,6 +106,22 @@ fun LauncherSettingsScreen(
)
Spacer(modifier = Modifier.height(8.dp))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(stringResource(R.string.settings_apps_section), style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(4.dp))
+ Button(onClick = onSyncApps, enabled = !isSyncingApps) {
+ Text(
+ if (isSyncingApps) stringResource(R.string.settings_apps_syncing)
+ else stringResource(R.string.action_sync_apps)
+ )
+ }
+ appSyncResultCount?.let { count ->
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(stringResource(R.string.settings_apps_synced, count), fontSize = 14.sp)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.settings_language_section), style = MaterialTheme.typography.titleMedium)
diff --git a/app/src/main/java/com/example/ichwillheim/PinScreen.kt b/app/src/main/java/com/example/ichwillheim/ui/PinScreen.kt
similarity index 97%
rename from app/src/main/java/com/example/ichwillheim/PinScreen.kt
rename to app/src/main/java/com/example/ichwillheim/ui/PinScreen.kt
index e3352d3..35dcd3e 100644
--- a/app/src/main/java/com/example/ichwillheim/PinScreen.kt
+++ b/app/src/main/java/com/example/ichwillheim/ui/PinScreen.kt
@@ -1,4 +1,4 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
@@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
+import com.example.ichwillheim.R
const val SETTINGS_PIN = "1234"
diff --git a/app/src/main/java/com/example/ichwillheim/SpeakerChoiceScreen.kt b/app/src/main/java/com/example/ichwillheim/ui/SpeakerChoiceScreen.kt
similarity index 97%
rename from app/src/main/java/com/example/ichwillheim/SpeakerChoiceScreen.kt
rename to app/src/main/java/com/example/ichwillheim/ui/SpeakerChoiceScreen.kt
index 5ee52a0..dae1b2b 100644
--- a/app/src/main/java/com/example/ichwillheim/SpeakerChoiceScreen.kt
+++ b/app/src/main/java/com/example/ichwillheim/ui/SpeakerChoiceScreen.kt
@@ -1,4 +1,4 @@
-package com.example.ichwillheim
+package com.example.ichwillheim.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.example.ichwillheim.R
@Composable
fun WorkflowSpeakerChoiceScreen(
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index d4498c0..5ee8de2 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -52,6 +52,11 @@
Systemsprache
Gerät
Geräte-ID
+ Apps
+ Installierte Apps synchronisieren
+ Wird synchronisiert…
+ %1$d Apps synchronisiert
+ Apps
Ablauf vor dem Anruf
Wählen Sie, welche Schritte vor jedem Anruf angezeigt werden.
Anruf-Overlay
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 8b02f6d..814f0f3 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -52,6 +52,11 @@
Idioma del sistema
Dispositivo
ID de dispositivo
+ Aplicaciones
+ Sincronizar aplicaciones instaladas
+ Sincronizando…
+ %1$d aplicaciones sincronizadas
+ Aplicaciones
Flujo de llamada
Elija qué pasos se muestran antes de cada llamada.
Superposición de llamada
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dcea4c7..fcb91a2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -54,6 +54,11 @@
System default
Device
Device ID
+ Apps
+ Sync installed apps
+ Syncing…
+ %1$d apps synced
+ Apps
Call flow
Choose which steps are shown before each call.
Call overlay
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9499910..a985dbe 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,6 +9,7 @@ espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.2"
composeBom = "2024.09.00"
+workRuntimeKtx = "2.10.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -17,6 +18,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
@@ -27,6 +29,7 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
+androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }