"Custom" and "Apps" Workflow types added. Fixed paginate error (20 rows max).
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
blocks = activeRun.currentStep?.contentBlocks ?: emptyList(),
|
||||
onAdvance = { resultCode -> viewModel.advance(resultCode) },
|
||||
onCancel = { viewModel.cancelActiveRun() }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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
|
||||
"show_app_list" -> AppListScreen(
|
||||
workflows = viewModel.settings.workflows,
|
||||
onStartWorkflow = { workflow -> viewModel.startWorkflow(workflow) },
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.example.ichwillheim
|
||||
package com.example.ichwillheim.domain
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
+3
-1
@@ -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(),
|
||||
)
|
||||
+2
-1
@@ -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"
|
||||
+3
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -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)
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+2
-1
@@ -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(
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user