"Custom" and "Apps" Workflow types added. Fixed paginate error (20 rows max).

This commit is contained in:
2026-07-16 14:00:33 +02:00
parent f6f999a153
commit ac68a6de28
21 changed files with 688 additions and 630 deletions
+39 -31
View File
@@ -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
```
---
+2
View File
@@ -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)
+18 -1
View File
@@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
@@ -39,9 +46,19 @@
</activity>
<service
android:name=".CallOverlayService"
android:name=".service.CallOverlayService"
android:foregroundServiceType="specialUse"
android:exported="false" />
<receiver
android:name=".service.PackageChangeReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_ADDED" />
<action android:name="android.intent.action.PACKAGE_REMOVED" />
<data android:scheme="package" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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<Workflow> = 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<GridCell>(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
)
}
}
@@ -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<WorkflowRunState?>(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
)
}
@@ -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) }
}
}
}
@@ -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<T>(val data: List<T>)
@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<NbStep> = 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<NbContentBlock> = 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<Workflow>? {
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<NbListResponse<NbWorkflow>>(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<NbListResponse<NbDeviceRef>>(raw)
.data.firstOrNull()?.id
} catch (e: Exception) {
Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}")
null
}
}
}
@@ -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<T>(val data: List<T>)
@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<NbStep> = 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<NbContentBlock> = 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<T>(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<Workflow>? {
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<NbListResponse<NbWorkflow>>(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<NbListResponse<NbWorkflowNameRef>>(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<NbSingleResponse<NbRef>>(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<NbSingleResponse<NbRef>>(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<NbListResponse<NbRef>>(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<NbListResponse<NbDeviceRef>>(raw)
.data.firstOrNull()?.id
} catch (e: Exception) {
Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}")
null
}
}
}
@@ -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
@@ -1,4 +1,4 @@
package com.example.ichwillheim
package com.example.ichwillheim.domain
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
@@ -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<WorkflowStep> = emptyList(),
)
@@ -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"
@@ -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.
@@ -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<Workflow> = 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
)
}
}
@@ -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)
@@ -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"
@@ -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(
+5
View File
@@ -52,6 +52,11 @@
<string name="settings_language_system">Systemsprache</string>
<string name="settings_device_section">Gerät</string>
<string name="settings_device_id">Geräte-ID</string>
<string name="settings_apps_section">Apps</string>
<string name="action_sync_apps">Installierte Apps synchronisieren</string>
<string name="settings_apps_syncing">Wird synchronisiert…</string>
<string name="settings_apps_synced">%1$d Apps synchronisiert</string>
<string name="app_list_title">Apps</string>
<string name="settings_call_flow_section">Ablauf vor dem Anruf</string>
<string name="settings_call_flow_description">Wählen Sie, welche Schritte vor jedem Anruf angezeigt werden.</string>
<string name="settings_call_overlay_section">Anruf-Overlay</string>
+5
View File
@@ -52,6 +52,11 @@
<string name="settings_language_system">Idioma del sistema</string>
<string name="settings_device_section">Dispositivo</string>
<string name="settings_device_id">ID de dispositivo</string>
<string name="settings_apps_section">Aplicaciones</string>
<string name="action_sync_apps">Sincronizar aplicaciones instaladas</string>
<string name="settings_apps_syncing">Sincronizando…</string>
<string name="settings_apps_synced">%1$d aplicaciones sincronizadas</string>
<string name="app_list_title">Aplicaciones</string>
<string name="settings_call_flow_section">Flujo de llamada</string>
<string name="settings_call_flow_description">Elija qué pasos se muestran antes de cada llamada.</string>
<string name="settings_call_overlay_section">Superposición de llamada</string>
+5
View File
@@ -54,6 +54,11 @@
<string name="settings_language_system">System default</string>
<string name="settings_device_section">Device</string>
<string name="settings_device_id">Device ID</string>
<string name="settings_apps_section">Apps</string>
<string name="action_sync_apps">Sync installed apps</string>
<string name="settings_apps_syncing">Syncing…</string>
<string name="settings_apps_synced">%1$d apps synced</string>
<string name="app_list_title">Apps</string>
<string name="settings_call_flow_section">Call flow</string>
<string name="settings_call_flow_description">Choose which steps are shown before each call.</string>
<string name="settings_call_overlay_section">Call overlay</string>
+3
View File
@@ -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" }