From ac68a6de2889e7743c1b9e911d6002490e98d321 Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 16 Jul 2026 14:00:33 +0200 Subject: [PATCH] "Custom" and "Apps" Workflow types added. Fixed paginate error (20 rows max). --- README.md | 70 +-- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 19 +- .../com/example/ichwillheim/HomeScreen.kt | 196 --------- .../com/example/ichwillheim/MainActivity.kt | 215 +++------ .../example/ichwillheim/PreCallStepsScreen.kt | 53 --- .../ichwillheim/RemoteConfigRepository.kt | 188 -------- .../data/RemoteConfigRepository.kt | 412 ++++++++++++++++++ .../{ => data}/SettingsRepository.kt | 3 +- .../{ => domain}/WorkflowEngine.kt | 2 +- .../{ => domain}/WorkflowModels.kt | 4 +- .../{ => service}/CallOverlayService.kt | 3 +- .../ichwillheim/{ => ui}/ContentScreen.kt | 4 +- .../com/example/ichwillheim/ui/HomeScreen.kt | 98 +++++ .../{ => ui}/LauncherSettingsScreen.kt | 25 +- .../example/ichwillheim/{ => ui}/PinScreen.kt | 3 +- .../{ => ui}/SpeakerChoiceScreen.kt | 3 +- app/src/main/res/values-de/strings.xml | 5 + app/src/main/res/values-es/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + gradle/libs.versions.toml | 3 + 21 files changed, 688 insertions(+), 630 deletions(-) delete mode 100644 app/src/main/java/com/example/ichwillheim/HomeScreen.kt delete mode 100644 app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt delete mode 100644 app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt create mode 100644 app/src/main/java/com/example/ichwillheim/data/RemoteConfigRepository.kt rename app/src/main/java/com/example/ichwillheim/{ => data}/SettingsRepository.kt (92%) rename app/src/main/java/com/example/ichwillheim/{ => domain}/WorkflowEngine.kt (97%) rename app/src/main/java/com/example/ichwillheim/{ => domain}/WorkflowModels.kt (78%) rename app/src/main/java/com/example/ichwillheim/{ => service}/CallOverlayService.kt (99%) rename app/src/main/java/com/example/ichwillheim/{ => ui}/ContentScreen.kt (97%) create mode 100644 app/src/main/java/com/example/ichwillheim/ui/HomeScreen.kt rename app/src/main/java/com/example/ichwillheim/{ => ui}/LauncherSettingsScreen.kt (83%) rename app/src/main/java/com/example/ichwillheim/{ => ui}/PinScreen.kt (97%) rename app/src/main/java/com/example/ichwillheim/{ => ui}/SpeakerChoiceScreen.kt (97%) 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" }