Code revision

This commit is contained in:
2026-07-07 09:38:39 +02:00
parent 9133103e82
commit f6f999a153
20 changed files with 2407 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
# IchWillHeim
A dementia-friendly Android home screen launcher. The patient sees a locked-down, simple screen. Caregivers configure everything remotely via a web app.
---
## Product architecture
The product is split into two distinct parts:
**1. Android launcher (this app)**
The only thing installed on the patient's device. It fetches its configuration from NocoBase on startup and renders a simple, locked-down home screen. No workflow configuration happens on the device.
**2. Caregiver web app**
Where caregivers manage everything: patients, devices, contacts, and workflows. Built in two phases:
- **Near term:** NocoBase's interface builder — Table, Form, Grid Card, and Details blocks are sufficient to build a functional caregiver dashboard with no frontend code. Caregivers can add/edit/remove workflows and contacts directly.
- **Long term:** a custom web app (and eventually mobile) with a proper onboarding questionnaire (ask what the patient needs → generate the first workflows automatically) plus a manual editor for ongoing changes.
---
## Settings on the device
### Patient-accessible settings
Simple, non-technical controls the patient can reach directly from the home screen:
- Font / button size
- Screen brightness
### PIN-locked advanced settings
Accessible via long-press (PIN-protected). The access screen is labelled **Settings Access** and the menu **Settings Menu**:
- **Device ID** — the identifier used to fetch this device's config from NocoBase
- **Language** — in-app language picker
- **Phone Settings** — opens the Android system settings (for Wi-Fi, accessibility, etc.)
Workflow configuration, home screen grid layout, and help screen editing are never done on the device — all managed via the web app.
---
## How the app works
### From the patient's perspective
The patient sees one screen with large buttons driven by workflows configured remotely. Pressing Back does nothing. Pressing Home always returns to this screen. No other apps are accessible.
### From the caregiver's perspective
Caregivers manage the patient's experience entirely through the web app (NocoBase or future custom app). On the device, they can access PIN-locked settings for device ID and language. They never need to touch workflow config on the device.
---
## Home screen grid
The home screen displays workflow buttons in a fixed 2-column grid. The caregiver sets `homepage_position` (1-indexed, left-to-right, top-to-bottom) and `button_span` (1 = one cell, 2 = wide / full row) for each workflow via the web app.
**Grid rendering rules (same logic in Android and web preview):**
1. Build a flat array of cells (size = effective grid size).
2. For each workflow sorted by position: place it at `position - 1`. If `button_span == 2` AND the button starts in the left column (even index) AND there is a next cell, mark the next cell as "consumed" so nothing else renders there.
3. If a `span-2` button starts in the right column, it is silently downgraded to `span-1` (would overflow the row).
4. **Auto-extend**: the configured `gridSize` (6 or 8, set in caregiver settings) is a minimum. If a workflow's position+span exceeds it, the grid grows to fit — `effectiveGridSize = max(configuredRows, rowsNeededByContent) × 2`.
The same `buildGridCells` algorithm runs in `HomeScreen.kt` (Android) and `Workflows.tsx` (web preview), so what the caregiver sees in the browser is exactly what the patient sees on their phone.
---
Every button on the home screen triggers a **workflow** — a sequence of steps. Each step calls one function (show a screen, place a call, etc.) and returns a result code. Steps execute in `sort_order` ascending; the engine automatically advances to the next step unless an `if` step redirects the flow.
```
Step 1 (sort=1) → Step 2 (sort=2) → if step → yes: Step 3 / no: Step 4 → …
```
Everything is data-driven. New buttons, screens, and branching logic are configured in the web app with no code changes.
### Step types
| 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`. |
| `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. |
`lastResultCode` is preserved across all steps, so a value set in step 2 is still readable by step 5.
### Result codes
| result_code | emitted by |
|---|---|
| `confirmed` | `show_call_confirm` (user tapped confirm) |
| `cancelled` | `show_call_confirm` or `show_speaker_choice` (user tapped cancel) |
| `speakerphone` | `show_speaker_choice` |
| `normal` | `show_speaker_choice` |
---
## NocoBase schema
**Server:** `https://nocobase.rucki.ch`
**API notes:**
- Filter only on direct fields of the collection — nested relation filters (e.g. `device.device_id`) are not supported. To filter workflows by device, do a two-step lookup: resolve `device_id` → device PK via `/api/devices:list`, then filter workflows by `device_key`.
- Appends use `appends[]` query parameter (array format).
- When creating relations, always specify the existing FK explicitly in advanced options. If you let NocoBase auto-generate, it creates a second FK column and rows added later won't appear in API responses.
- Create belongsTo relations first (they create the FK column), then hasMany reusing the same FK.
---
### Tables
| Table | Purpose |
|---|---|
| `device_users` | One row per patient (renamed from `beneficiaries`) |
| `devices` | One row per physical device; linked to a device user |
| `contacts` | Contacts; linked to device user (not device — shared across all their devices) |
| `workflow_function` | Global catalog of executable functions (defined by the app) |
| `workflow` | One row per home screen button per device |
| `workflow_step` | Steps within a workflow; each calls one function |
| `content_block` | Ordered UI content (heading / text / button) for screen steps |
| `event_log` | One row per completed workflow step — used by the caregiver dashboard |
---
### `device_users`
| field | type |
|---|---|
| `name` | string |
| `date_of_birth` | date |
| `notes` | text |
---
### `devices`
| field | type | notes |
|---|---|---|
| `device_id` | string | unique; used by the app to fetch its config (underscores, e.g. `redmi_test`) |
| `label` | string | human-readable name e.g. "Renate's tablet" |
| `beneficiary` | belongsTo → beneficiaries | |
---
### `contacts`
| field | type | notes |
|---|---|---|
| `name` | string | |
| `phone` | string | |
| `photo_url` | text (url) | |
| `device_user` | belongsTo → device_users | linked to the person, shared across all their devices |
---
### `workflow_function` — global catalog, defined by the app
| field | type | notes |
|---|---|---|
| `function_id` | string | unique identifier used by the app |
| `name` | string | displayed in the web app step editor |
| `description` | text | |
**Current entries:**
| function_id | name |
|---|---|
| `show_call_confirm` | Show Call Confirm |
| `show_speaker_choice` | Show Speaker Choice |
| `call_contact` | Call Contact |
| `if` | If / Branch |
| `label` | Label |
---
### `workflow` — one per home screen button per device
| field | type | notes |
|---|---|---|
| `name` | string | e.g. "Call Sandro" |
| `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 | |
| `device` | belongsTo → devices | |
| `workflow_steps` | hasMany → workflow_step | fetched via `workflow_steps` append |
---
### `workflow_step` — steps execute in `sort_order` ascending; lowest = start step
| field | type | notes |
|---|---|---|
| `name` | string | |
| `sort_order` | integer | lowest value = start step |
| `parameter_json` | text | static inputs e.g. `{"phone": "+41768224400"}` |
| `function` | belongsTo → workflow_function | app reads `function_id` from the related record |
| `if_result_code` | string / null | `if` steps only: the `lastResultCode` value to match |
| `then_step` | belongsTo → workflow_step / null | `if` steps only: jump target when condition is true |
| `else_step` | belongsTo → workflow_step / null | `if` steps only: jump target when condition is false |
| `content_block` | hasMany → content_block | fetched via `workflow_steps.content_block` append |
> **Unused DB fields** (can be deleted from NocoBase): `output_id`, `input_mapping_json`, `is_active`
---
### `event_log` — one row per completed workflow step
Written by the Android app fire-and-forget after each step. No UI impact.
| field | type | notes |
|---|---|---|
| `device_id` | string | matches `devices.device_id` |
| `workflow_name` | string | name of the running workflow |
| `function_id` | string | step type that completed |
| `result_code` | string / null | outcome (`confirmed`, `cancelled`, `speakerphone`, `normal`, or null for automatic steps) |
| `duration_seconds` | number | time the patient spent on this step |
| `created_at` | datetime | auto-set by NocoBase |
The caregiver web app dashboard groups these rows by device user → workflow → function+outcome to show usage statistics.
---
### `content_block` — ordered content elements for screen steps
| field | type | notes |
|---|---|---|
| `type` | select | `heading`, `text`, `image`, `button` |
| `value` | text | display text, image URL, or button label |
| `sort_order` | integer | render order |
| `result_code` | string | `button` type only — emitted when tapped |
| `workflow_step` | belongsTo → workflow_step | |
---
### Relation map
| Relation | Type | FK column |
|---|---|---|
| `devices.device_user` | belongsTo → device_users | `device_users_key` on `devices` |
| `contacts.device_user` | belongsTo → device_users | `device_users_key` on `contacts` |
| `workflow.device` | belongsTo → devices | `device_key` on `workflow` |
| `workflow.workflow_steps` | hasMany → workflow_step | `workflow_key` on `workflow_step` |
| `workflow_step.function` | belongsTo → workflow_function | `function_key` on `workflow_step` |
| `workflow_step.then_step` | belongsTo → workflow_step | `then_step_key` on `workflow_step` |
| `workflow_step.else_step` | belongsTo → workflow_step | `else_step_key` on `workflow_step` |
| `workflow_step.content_block` | hasMany → content_block | `content_block_key` on `content_block` |
---
## Example: Call Sandro (simple — no speaker choice)
| sort_order | function_id | parameter_json |
|---|---|---|
| 1 | `show_call_confirm` | — |
| 2 | `call_contact` | `{"phone": "+41768224400"}` |
Steps run in order. No transitions, no if steps needed.
---
## Example: Call Sandro (with speaker choice via if step)
| 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}` | — | — | — |
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).
---
## File structure
```
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
```
---
## Internationalisation
All user-facing strings live in `res/values/strings.xml` (English default).
| Folder | Language |
|---|---|
| `res/values/` | English (default) |
| `res/values-de/` | German |
| `res/values-es/` | Spanish |
---
## Permissions
| Permission | Why |
|---|---|
| `CALL_PHONE` | Place calls directly without the dialer |
| `READ_PHONE_STATE` | Detect call state (for speakerphone timing) |
| `ANSWER_PHONE_CALLS` | Answer incoming calls from the overlay |
| `MODIFY_AUDIO_SETTINGS` | Switch to speakerphone |
| `SYSTEM_ALERT_WINDOW` | Draw call overlay — must be granted manually |
| `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_SPECIAL_USE` | Run CallOverlayService |
| `POST_NOTIFICATIONS` | Required on Android 13+ for foreground service notification |
---
## Security
The app authenticates to NocoBase via a `Bearer` token in every request. The token is stored in `local.properties` (never committed) and injected at build time via `BuildConfig`.
**Before any public release:** replace with a proper auth layer — caregiver accounts, server-side token exchange, per-user data isolation in NocoBase. The database schema does not need to change; only the authentication mechanism on top of it.
---
## Color palette
Both the Android launcher and the caregiver web app use the same three-color system:
| Role | Hex | Usage |
|---|---|---|
| Background / surface | `#FFF6E5` | Screen background, sidebar, cards |
| Primary | `#A6CDB2` | Buttons, active nav items, focus rings |
| Accent | `#FCAB7E` | Cancel buttons, secondary actions, highlights |
| Text | `#2D2D2D` | All foreground text on the above backgrounds |
All three background colors pass WCAG AA contrast with `#2D2D2D` text (≥ 7:1).
---
## Ideas backlog
- **Custom caregiver web app** — onboarding questionnaire (generates first workflows) + manual editor; eventually iOS/Android caregiver app
- **Unified inbox** — SMS + WhatsApp in one patient-facing screen
- **Photo slideshow management** — upload/manage slideshow photos remotely via NocoBase
- **Message effectiveness tracking** — log whether reminders led to calls or cancellations
- **Caregiver accounts** — multiple caregivers per patient, each with their own login
- **Community platform** — caregivers share workflow presets, tips, and experiences
@@ -0,0 +1,328 @@
package com.example.ichwillheim
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.PixelFormat
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.IBinder
import android.telecom.TelecomManager
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import android.view.WindowManager
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.painterResource
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 androidx.core.app.NotificationCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
const val EXTRA_SPEAKERPHONE = "speakerphone"
private const val CHANNEL_ID = "call_overlay_channel"
private const val NOTIFICATION_ID = 1001
class CallOverlayService : Service() {
private lateinit var windowManager: WindowManager
private var overlayView: ComposeView? = null
private val lifecycleOwner = ServiceLifecycleOwner()
// Observed by the Compose content inside the overlay
private var speakerphoneOn by mutableStateOf(false)
// Prevents stopping the service on the initial IDLE state at registration time
private var hasBeenOffHook = false
private var telephonyCallback: TelephonyCallback? = null
@Suppress("DEPRECATION")
private var legacyPhoneStateListener: PhoneStateListener? = null
override fun onCreate() {
super.onCreate()
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
lifecycleOwner.start()
startAsForeground()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
speakerphoneOn = intent?.getBooleanExtra(EXTRA_SPEAKERPHONE, false) ?: false
showOverlay()
monitorCallState()
return START_NOT_STICKY
}
private fun startAsForeground() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "Anruf-Erinnerung", NotificationManager.IMPORTANCE_LOW
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Anruf läuft")
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun showOverlay() {
if (overlayView != null) return
val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
PixelFormat.TRANSLUCENT
)
val view = ComposeView(this).apply {
setViewTreeLifecycleOwner(lifecycleOwner)
setViewTreeSavedStateRegistryOwner(lifecycleOwner)
setContent {
IchWillHeimTheme {
CallOverlayContent(
speakerphoneOn = speakerphoneOn,
onToggleSpeaker = { toggleSpeakerphone() },
onHangUp = { hangUp() }
)
}
}
}
windowManager.addView(view, params)
overlayView = view
}
private fun toggleSpeakerphone() {
speakerphoneOn = !speakerphoneOn
val audio = getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (speakerphoneOn) setOverlaySpeakerphoneOn(audio) else setOverlaySpeakerphoneOff(audio)
}
private fun hangUp() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val telecom = getSystemService(TELECOM_SERVICE) as TelecomManager
try { telecom.endCall() } catch (_: SecurityException) { }
}
}
@Suppress("DEPRECATION")
private fun monitorCallState() {
val telephony = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) = handleCallState(state)
}
telephonyCallback = callback
telephony.registerTelephonyCallback(mainExecutor, callback)
} else {
val listener = object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) =
handleCallState(state)
}
legacyPhoneStateListener = listener
telephony.listen(listener, PhoneStateListener.LISTEN_CALL_STATE)
}
}
private fun handleCallState(state: Int) {
when (state) {
TelephonyManager.CALL_STATE_OFFHOOK -> {
hasBeenOffHook = true
if (speakerphoneOn) {
val audio = getSystemService(Context.AUDIO_SERVICE) as AudioManager
setOverlaySpeakerphoneOn(audio)
}
}
TelephonyManager.CALL_STATE_IDLE -> {
// Guard against the initial IDLE fired at listener registration time
if (hasBeenOffHook) stopSelf()
}
}
}
override fun onDestroy() {
val telephony = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
telephonyCallback?.let { telephony.unregisterTelephonyCallback(it) }
} else {
@Suppress("DEPRECATION")
legacyPhoneStateListener?.let { telephony.listen(it, PhoneStateListener.LISTEN_NONE) }
}
overlayView?.let { try { windowManager.removeView(it) } catch (_: Exception) { } }
overlayView = null
lifecycleOwner.stop()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
private fun setOverlaySpeakerphoneOn(audioManager: AudioManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val speaker = audioManager.availableCommunicationDevices
.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
if (speaker != null) audioManager.setCommunicationDevice(speaker)
} else {
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = true
}
}
private fun setOverlaySpeakerphoneOff(audioManager: AudioManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
} else {
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = false
}
}
@Composable
private fun CallOverlayContent(
speakerphoneOn: Boolean,
onToggleSpeaker: () -> Unit,
onHangUp: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(
id = if (speakerphoneOn) R.drawable.speakeron else R.drawable.speakeroff
),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.weight(0.7f)
.fillMaxWidth()
.padding(32.dp)
)
Text(
text = if (speakerphoneOn)
"Halten Sie das Gerät vor sich"
else
"Halten Sie das Telefon ans Ohr",
fontSize = 22.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
modifier = Modifier
.weight(0.1f)
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
Row(
modifier = Modifier
.weight(0.2f)
.fillMaxWidth()
.padding(horizontal = 64.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton(
onClick = onToggleSpeaker,
modifier = Modifier.size(64.dp)
) {
Image(
painter = painterResource(
id = if (speakerphoneOn) R.drawable.speakeron else R.drawable.speakeroff
),
contentDescription = stringResource(R.string.cd_toggle_speakerphone)
)
}
Text(stringResource(R.string.speaker_speakerphone), fontSize = 12.sp, textAlign = TextAlign.Center)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton(
onClick = onHangUp,
modifier = Modifier
.size(64.dp)
.background(Color(0xFFD32F2F), CircleShape)
) {
Icon(
imageVector = Icons.Default.CallEnd,
contentDescription = stringResource(R.string.overlay_hang_up),
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Text(stringResource(R.string.overlay_hang_up), fontSize = 12.sp, textAlign = TextAlign.Center)
}
}
}
}
private class ServiceLifecycleOwner : SavedStateRegistryOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
private val savedStateController = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle = lifecycleRegistry
override val savedStateRegistry: SavedStateRegistry = savedStateController.savedStateRegistry
fun start() {
savedStateController.performRestore(null)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}
fun stop() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
}
@@ -0,0 +1,130 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.ui.Modifier
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
/**
* Generic content screen for data-driven workflow steps.
*
* Renders a list of ContentBlocks (heading / text / image / button) fetched
* from NocoBase. Adding a new content step never requires an app update —
* just add blocks in the caregiver web app.
*
* Block types:
* "heading" — large bold title, centred
* "text" — body paragraph
* "image" — placeholder until Coil is added (TODO)
* "button" — full-width tappable button; advances the workflow
*
* If no "button" block is present a default "Next" button is shown at the bottom.
*/
@Composable
fun ContentScreen(
blocks: List<ContentBlock>,
onAdvance: (String?) -> Unit,
onCancel: () -> Unit
) {
BackHandler { onCancel() }
val sorted = blocks.sortedBy { it.position }
val hasButton = sorted.any { it.type == "button" }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
// Close button — top-right corner
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(onClick = onCancel) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_close)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
sorted.forEach { block ->
when (block.type) {
"heading" -> Text(
text = block.value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
"text" -> Text(
text = block.value,
style = MaterialTheme.typography.bodyLarge,
fontSize = 20.sp,
modifier = Modifier.padding(vertical = 4.dp)
)
"image" -> {
// TODO: replace with AsyncImage once Coil is added to dependencies
Text(
text = "🖼 ${block.value}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(vertical = 4.dp)
)
}
"button" -> {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { onAdvance(block.resultCode) },
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
) {
Text(block.value, fontSize = 20.sp)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Fallback: if the step has no button block, show a generic "Next" button
if (!hasButton) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { onAdvance(null) },
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
) {
Text(stringResource(R.string.action_next), fontSize = 20.sp)
}
}
}
}
@@ -0,0 +1,196 @@
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
)
}
}
@@ -0,0 +1,145 @@
package com.example.ichwillheim
import androidx.activity.compose.BackHandler
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
@Composable
fun SettingsMenuScreen(
onLauncherSettings: () -> Unit,
onPhoneSettings: () -> Unit,
onClose: () -> Unit
) {
BackHandler { onClose() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.caregiver_menu_title), style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onLauncherSettings,
modifier = Modifier.fillMaxWidth().height(80.dp)
) {
Text(stringResource(R.string.launcher_settings_title), fontSize = 20.sp)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onPhoneSettings,
modifier = Modifier.fillMaxWidth().height(80.dp)
) {
Text(stringResource(R.string.caregiver_menu_phone_settings), fontSize = 20.sp)
}
Spacer(modifier = Modifier.height(32.dp))
TextButton(onClick = onClose) { Text(stringResource(R.string.action_cancel)) }
}
}
@Composable
fun LauncherSettingsScreen(
settings: AppSettings,
onSettingsChanged: (AppSettings) -> Unit,
onClose: () -> Unit
) {
BackHandler { onClose() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.launcher_settings_title), style = MaterialTheme.typography.headlineSmall)
TextButton(onClick = onClose) { Text(stringResource(R.string.action_done)) }
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.settings_device_section), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = settings.deviceId,
onValueChange = { onSettingsChanged(settings.copy(deviceId = it.trim())) },
label = { Text(stringResource(R.string.settings_device_id)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.settings_language_section), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
LanguagePicker()
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun LanguagePicker() {
val currentTag = AppCompatDelegate.getApplicationLocales().let {
if (it.isEmpty) "" else it[0]?.language ?: ""
}
val systemLabel = stringResource(R.string.settings_language_system)
val options = listOf(
"" to systemLabel,
"en" to "English",
"de" to "Deutsch",
"es" to "Español",
)
Column {
options.forEach { (tag, name) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
AppCompatDelegate.setApplicationLocales(
if (tag.isEmpty()) LocaleListCompat.getEmptyLocaleList()
else LocaleListCompat.forLanguageTags(tag)
)
}
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = currentTag == tag, onClick = null)
Text(name, fontSize = 15.sp, modifier = Modifier.padding(start = 4.dp))
}
}
}
}
@@ -0,0 +1,84 @@
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.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
const val SETTINGS_PIN = "1234"
@Composable
fun PinScreen(
onCorrectPin: () -> Unit,
onCancel: () -> Unit
) {
BackHandler { onCancel() }
var pin by remember { mutableStateOf("") }
var error by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.pin_screen_title), style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = pin,
onValueChange = { input ->
if (input.length <= 4 && input.all { it.isDigit() }) {
pin = input
error = false
}
},
label = { Text(stringResource(R.string.field_pin)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
isError = error,
supportingText = { if (error) Text(stringResource(R.string.pin_error_incorrect)) }
)
Spacer(modifier = Modifier.height(24.dp))
Row {
TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) }
Spacer(modifier = Modifier.width(16.dp))
Button(onClick = {
if (pin == SETTINGS_PIN) {
onCorrectPin()
} else {
error = true
pin = ""
}
}) {
Text(stringResource(R.string.action_confirm))
}
}
}
}
@@ -0,0 +1,53 @@
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) }
}
}
}
@@ -0,0 +1,188 @@
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,31 @@
package com.example.ichwillheim
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class AppSettings(
val deviceId: String = "",
val workflows: List<Workflow> = emptyList(),
val gridSize: Int = 6
)
private val json = Json { ignoreUnknownKeys = true }
private const val SETTINGS_PREFS = "launcher_settings"
private const val KEY_APP_SETTINGS = "app_settings"
object SettingsRepository {
fun load(context: Context): AppSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE)
val raw = prefs.getString(KEY_APP_SETTINGS, null) ?: return AppSettings()
return try { json.decodeFromString(raw) } catch (_: Exception) { AppSettings() }
}
fun save(context: Context, settings: AppSettings) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_APP_SETTINGS, Json.encodeToString(settings)).apply()
}
}
@@ -0,0 +1,89 @@
package com.example.ichwillheim
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
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
@Composable
fun WorkflowSpeakerChoiceScreen(
onChoice: (String) -> Unit,
onCancel: () -> Unit
) {
BackHandler { onCancel() }
val normalLabel = stringResource(R.string.speaker_normal_call)
val speakerLabel = stringResource(R.string.speaker_speakerphone)
Column(modifier = Modifier.fillMaxSize()) {
SpeakerOptionHalf(
imageRes = R.drawable.speakeroff,
label = normalLabel,
backgroundColor = Color(0xFFF1F8E9),
onClick = { onChoice("normal") },
modifier = Modifier.weight(1f).fillMaxWidth()
)
Box(
modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFFBDBDBD))
)
SpeakerOptionHalf(
imageRes = R.drawable.speakeron,
label = speakerLabel,
backgroundColor = Color(0xFFE3F2FD),
onClick = { onChoice("speakerphone") },
modifier = Modifier.weight(1f).fillMaxWidth()
)
}
}
@Composable
private fun SpeakerOptionHalf(
imageRes: Int,
label: String,
backgroundColor: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = label,
contentScale = ContentScale.Fit,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = label,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
}
@@ -0,0 +1,46 @@
package com.example.ichwillheim
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class WorkflowRunState(
val workflow: Workflow,
val currentStep: WorkflowStep?,
val stepContext: Map<String, String> = emptyMap(),
val isFinished: Boolean = false,
val lastResultCode: String? = null
) {
fun advance(resultCode: String?): WorkflowRunState {
val step = currentStep ?: return copy(isFinished = true)
if (step.functionKey == "if") {
val matched = step.ifResultCode != null && lastResultCode == step.ifResultCode
val targetId = if (matched) step.thenStepId else step.elseStepId
val nextStep = targetId?.let { id -> workflow.steps.firstOrNull { it.id == id } }
?: workflow.steps.filter { it.sortOrder > step.sortOrder }.minByOrNull { it.sortOrder }
return copy(currentStep = nextStep, isFinished = nextStep == null)
}
val nextStep = workflow.steps.filter { it.sortOrder > step.sortOrder }.minByOrNull { it.sortOrder }
return copy(currentStep = nextStep, isFinished = nextStep == null, lastResultCode = resultCode)
}
fun param(key: String): String? {
val raw = currentStep?.parameterJson?.takeIf { it.isNotBlank() } ?: return null
return try {
Json.parseToJsonElement(raw).jsonObject[key]?.jsonPrimitive?.content
} catch (_: Exception) { null }
}
companion object {
fun start(workflow: Workflow): WorkflowRunState {
val startStep = workflow.steps.minByOrNull { it.sortOrder }
return WorkflowRunState(
workflow = workflow,
currentStep = startStep,
isFinished = startStep == null
)
}
}
}
@@ -0,0 +1,35 @@
package com.example.ichwillheim
import kotlinx.serialization.Serializable
@Serializable
data class ContentBlock(
val id: Long,
val type: String, // "heading", "text", "image", "button"
val value: String,
val position: Int,
val resultCode: String? = null,
)
@Serializable
data class WorkflowStep(
val id: Long,
val name: String,
val functionKey: String,
val sortOrder: Int,
val parameterJson: String? = null,
val ifResultCode: String? = null,
val thenStepId: Long? = null,
val elseStepId: Long? = null,
val contentBlocks: List<ContentBlock> = emptyList()
)
@Serializable
data class Workflow(
val id: Long,
val name: String,
val icon: String? = null,
val homepagePosition: Int? = null,
val buttonSpan: Int = 1, // 1 = one cell, 2 = full row (both columns)
val steps: List<WorkflowStep> = emptyList(),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+114
View File
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Common actions -->
<string name="action_cancel">Abbrechen</string>
<string name="action_done">Fertig</string>
<string name="action_save">Speichern</string>
<string name="action_confirm">Bestätigen</string>
<string name="action_next">Weiter</string>
<string name="action_close">Schließen</string>
<string name="action_call">Anrufen</string>
<string name="action_open_settings">Einstellungen öffnen</string>
<string name="action_add_contact">Kontakt hinzufügen</string>
<string name="action_add_guide">Guide hinzufügen</string>
<string name="action_add_step">Schritt hinzufügen</string>
<string name="action_edit_help_screen">Hilfe-Bildschirm bearbeiten</string>
<!-- Content descriptions -->
<string name="cd_previous">Zurück</string>
<string name="cd_next">Weiter</string>
<string name="cd_back">Zurück</string>
<string name="cd_caregiver_settings">Einstellungen</string>
<string name="cd_edit_contact">%1$s bearbeiten</string>
<string name="cd_delete_contact">%1$s löschen</string>
<string name="cd_edit_steps">Schritte bearbeiten</string>
<string name="cd_delete_guide">Guide löschen</string>
<string name="cd_edit_step">Schritt bearbeiten</string>
<string name="cd_delete_step">Schritt löschen</string>
<!-- Home screen -->
<string name="home_new_messages">Sie haben neue Nachrichten</string>
<string name="btn_contacts">Kontakte</string>
<string name="btn_messages">Nachrichten</string>
<string name="btn_camera">Kamera</string>
<string name="photo_placeholder">Foto %1$d</string>
<!-- Favorites screen -->
<string name="favorites_who_to_call">Wen möchten Sie anrufen?</string>
<!-- Caregiver menu -->
<string name="caregiver_menu_title">Einstellungsmenü</string>
<string name="launcher_settings_title">Einstellungen</string>
<string name="caregiver_menu_phone_settings">Telefoneinstellungen</string>
<!-- PIN screen -->
<string name="pin_screen_title">Einstellungszugang</string>
<string name="field_pin">PIN</string>
<string name="pin_error_incorrect">Falscher PIN</string>
<!-- Launcher settings -->
<string name="edit_contacts_title">Kontakte bearbeiten</string>
<string name="settings_language_section">Sprache</string>
<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_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>
<string name="settings_call_overlay_label">Overlay während des Anrufs</string>
<string name="step_reminders_label">Erinnerungen</string>
<string name="step_reminders_desc">Wichtige Informationen vor dem Anruf</string>
<string name="step_help_offer_label">Hilfe anbieten</string>
<string name="step_help_offer_desc">Fragen ob der Patient Hilfe benötigt</string>
<string name="step_call_confirm_label">Anruf bestätigen</string>
<string name="step_call_confirm_desc">Anruf bestätigen lassen</string>
<string name="step_speaker_choice_label">Lautsprecherwahl</string>
<string name="step_speaker_choice_desc">Normal- oder Lautsprecheranruf wählen</string>
<!-- Overlay permission dialog -->
<string name="overlay_permission_title">Berechtigung erforderlich</string>
<string name="overlay_permission_text">Um die Anruf-Erinnerung anzuzeigen, erteilen Sie bitte die Berechtigung \"Über anderen Apps anzeigen\" in den Einstellungen.</string>
<!-- Contacts screen -->
<string name="dialog_add_contact">Kontakt hinzufügen</string>
<string name="dialog_edit_contact">Kontakt bearbeiten</string>
<string name="field_name">Name</string>
<string name="field_phone">Telefonnummer</string>
<string name="field_reminder_message">Erinnerungsnachricht (optional)</string>
<!-- Pre-call steps -->
<string name="reminders_step_title">Bevor Sie %1$s anrufen</string>
<string name="call_confirm_title">Möchten Sie %1$s anrufen?</string>
<string name="help_offer_no_guides">Keine Hilfe verfügbar.</string>
<string name="guide_no_steps">Keine Schritte vorhanden.</string>
<string name="guide_step_counter">%1$d / %2$d</string>
<!-- Help config screen -->
<string name="help_config_title">Hilfe-Bildschirm</string>
<string name="field_title">Titel</string>
<string name="field_subtitle">Untertitel (optional)</string>
<string name="help_config_guides_section">Guides</string>
<string name="dialog_add_guide">Guide hinzufügen</string>
<string name="dialog_rename_guide">Guide umbenennen</string>
<string name="dialog_add_step">Schritt hinzufügen</string>
<string name="dialog_edit_step">Schritt bearbeiten</string>
<string name="field_text">Text</string>
<!-- Speaker choice and call overlay -->
<string name="speaker_normal_call">Normaler Anruf</string>
<string name="speaker_speakerphone">Lautsprecher</string>
<string name="overlay_hang_up">Auflegen</string>
<string name="cd_toggle_speakerphone">Lautsprecher umschalten</string>
<!-- Errors and permissions -->
<string name="error_phone_app_not_found">Telefon-App nicht gefunden</string>
<string name="error_messages_app_not_found">Nachrichten-App nicht gefunden</string>
<string name="error_camera_app_not_found">Kamera-App nicht gefunden</string>
<string name="permission_call_missing">Anrufberechtigung fehlt</string>
<!-- Plurals -->
<plurals name="guide_step_count">
<item quantity="one">1 Schritt</item>
<item quantity="other">%d Schritte</item>
</plurals>
</resources>
+114
View File
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Common actions -->
<string name="action_cancel">Cancelar</string>
<string name="action_done">Listo</string>
<string name="action_save">Guardar</string>
<string name="action_confirm">Confirmar</string>
<string name="action_next">Siguiente</string>
<string name="action_close">Cerrar</string>
<string name="action_call">Llamar</string>
<string name="action_open_settings">Abrir ajustes</string>
<string name="action_add_contact">Añadir contacto</string>
<string name="action_add_guide">Añadir guía</string>
<string name="action_add_step">Añadir paso</string>
<string name="action_edit_help_screen">Editar pantalla de ayuda</string>
<!-- Content descriptions -->
<string name="cd_previous">Anterior</string>
<string name="cd_next">Siguiente</string>
<string name="cd_back">Atrás</string>
<string name="cd_caregiver_settings">Ajustes del cuidador</string>
<string name="cd_edit_contact">Editar %1$s</string>
<string name="cd_delete_contact">Eliminar %1$s</string>
<string name="cd_edit_steps">Editar pasos</string>
<string name="cd_delete_guide">Eliminar guía</string>
<string name="cd_edit_step">Editar paso</string>
<string name="cd_delete_step">Eliminar paso</string>
<!-- Home screen -->
<string name="home_new_messages">Tiene mensajes nuevos</string>
<string name="btn_contacts">Contactos</string>
<string name="btn_messages">Mensajes</string>
<string name="btn_camera">Cámara</string>
<string name="photo_placeholder">Foto %1$d</string>
<!-- Favorites screen -->
<string name="favorites_who_to_call">¿A quién desea llamar?</string>
<!-- Caregiver menu -->
<string name="caregiver_menu_title">Menú del cuidador</string>
<string name="launcher_settings_title">Ajustes del lanzador</string>
<string name="caregiver_menu_phone_settings">Ajustes del teléfono</string>
<!-- PIN screen -->
<string name="pin_screen_title">Acceso del cuidador</string>
<string name="field_pin">PIN</string>
<string name="pin_error_incorrect">PIN incorrecto</string>
<!-- Launcher settings -->
<string name="edit_contacts_title">Editar contactos</string>
<string name="settings_language_section">Idioma</string>
<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_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>
<string name="settings_call_overlay_label">Superposición durante la llamada</string>
<string name="step_reminders_label">Recordatorios</string>
<string name="step_reminders_desc">Información importante antes de la llamada</string>
<string name="step_help_offer_label">Ofrecer ayuda</string>
<string name="step_help_offer_desc">Preguntar si el paciente necesita ayuda</string>
<string name="step_call_confirm_label">Confirmar llamada</string>
<string name="step_call_confirm_desc">Pedir al paciente que confirme la llamada</string>
<string name="step_speaker_choice_label">Elección de altavoz</string>
<string name="step_speaker_choice_desc">Elegir entre llamada normal o con altavoz</string>
<!-- Overlay permission dialog -->
<string name="overlay_permission_title">Permiso requerido</string>
<string name="overlay_permission_text">Para mostrar la superposición de llamada, conceda el permiso \"Mostrar sobre otras apps\" en los ajustes.</string>
<!-- Contacts screen -->
<string name="dialog_add_contact">Añadir contacto</string>
<string name="dialog_edit_contact">Editar contacto</string>
<string name="field_name">Nombre</string>
<string name="field_phone">Número de teléfono</string>
<string name="field_reminder_message">Mensaje recordatorio (opcional)</string>
<!-- Pre-call steps -->
<string name="reminders_step_title">Antes de llamar a %1$s</string>
<string name="call_confirm_title">¿Desea llamar a %1$s?</string>
<string name="help_offer_no_guides">No hay ayuda disponible.</string>
<string name="guide_no_steps">No hay pasos disponibles.</string>
<string name="guide_step_counter">%1$d / %2$d</string>
<!-- Help config screen -->
<string name="help_config_title">Pantalla de ayuda</string>
<string name="field_title">Título</string>
<string name="field_subtitle">Subtítulo (opcional)</string>
<string name="help_config_guides_section">Guías</string>
<string name="dialog_add_guide">Añadir guía</string>
<string name="dialog_rename_guide">Renombrar guía</string>
<string name="dialog_add_step">Añadir paso</string>
<string name="dialog_edit_step">Editar paso</string>
<string name="field_text">Texto</string>
<!-- Speaker choice and call overlay -->
<string name="speaker_normal_call">Llamada normal</string>
<string name="speaker_speakerphone">Altavoz</string>
<string name="overlay_hang_up">Colgar</string>
<string name="cd_toggle_speakerphone">Alternar altavoz</string>
<!-- Errors and permissions -->
<string name="error_phone_app_not_found">Aplicación de teléfono no encontrada</string>
<string name="error_messages_app_not_found">Aplicación de mensajes no encontrada</string>
<string name="error_camera_app_not_found">Aplicación de cámara no encontrada</string>
<string name="permission_call_missing">Permiso de llamada faltante</string>
<!-- Plurals -->
<plurals name="guide_step_count">
<item quantity="one">1 paso</item>
<item quantity="other">%d pasos</item>
</plurals>
</resources>
+36
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

+60
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

+210
View File
@@ -0,0 +1,210 @@
# Workflow-Based Android Application -- Concept & Database Design
## Overview
This document describes the core idea and database structure for a
workflow-based Android application.
A **workflow** consists of multiple **steps**. Each step: - Executes a
function - Receives input data - Produces a result - Determines the next
step based on the result
Workflows can be triggered via UI elements such as buttons.
------------------------------------------------------------------------
## Core Concept
### Workflow Execution Flow
1. A workflow is started (e.g., by pressing a button)
2. The first step is executed
3. The step calls a function
4. The function returns a result
5. Based on the result, the next step is determined
6. The process continues until no next step is defined
------------------------------------------------------------------------
## Context Handling
A shared **context object** is used to pass data between steps.
Example: - Step 1 stores selected contact → `SelectedContact` - Step 4
reads `SelectedContact`
------------------------------------------------------------------------
## Database Schema
### 1. workflows
Stores workflow definitions.
``` sql
CREATE TABLE workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
start_step_id INTEGER,
is_active INTEGER NOT NULL DEFAULT 1
);
```
------------------------------------------------------------------------
### 2. workflow_steps
Defines steps within a workflow.
``` sql
CREATE TABLE workflow_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL,
step_key TEXT NOT NULL,
name TEXT NOT NULL,
function_key TEXT NOT NULL,
input_mapping_json TEXT,
parameter_json TEXT,
output_key TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (workflow_id) REFERENCES workflows(id)
);
```
------------------------------------------------------------------------
### 3. workflow_transitions
Defines transitions between steps.
``` sql
CREATE TABLE workflow_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL,
from_step_id INTEGER NOT NULL,
result_code TEXT NOT NULL,
to_step_id INTEGER,
is_default INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (workflow_id) REFERENCES workflows(id),
FOREIGN KEY (from_step_id) REFERENCES workflow_steps(id),
FOREIGN KEY (to_step_id) REFERENCES workflow_steps(id)
);
```
------------------------------------------------------------------------
### 4. workflow_functions
Describes available executable functions.
``` sql
CREATE TABLE workflow_functions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
function_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
input_schema_json TEXT,
parameter_schema_json TEXT,
output_schema_json TEXT,
is_active INTEGER NOT NULL DEFAULT 1
);
```
------------------------------------------------------------------------
### 5. workflow_buttons
Defines UI triggers for workflows.
``` sql
CREATE TABLE workflow_buttons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
caption TEXT NOT NULL,
icon TEXT,
workflow_id INTEGER NOT NULL,
group_name TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (workflow_id) REFERENCES workflows(id)
);
```
------------------------------------------------------------------------
### 6. workflow_runs
Tracks workflow executions.
``` sql
CREATE TABLE workflow_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL,
context_json TEXT,
FOREIGN KEY (workflow_id) REFERENCES workflows(id)
);
```
------------------------------------------------------------------------
### 7. workflow_run_steps
Tracks execution of each step.
``` sql
CREATE TABLE workflow_run_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_run_id INTEGER NOT NULL,
step_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL,
input_json TEXT,
output_json TEXT,
result_code TEXT,
error_message TEXT,
FOREIGN KEY (workflow_run_id) REFERENCES workflow_runs(id),
FOREIGN KEY (step_id) REFERENCES workflow_steps(id)
);
```
------------------------------------------------------------------------
## Example: Call Workflow
### Steps
1. Select contact
2. If contact is "Peter" → ask confirmation
3. If confirmed → call
4. Otherwise → end
### Transitions
From Step Result Code To Step
---------------- ------------- ---------
Select Contact PETER Ask
Select Contact NOT_PETER Call
Ask YES Call
Ask NO End
------------------------------------------------------------------------
## Summary
Key components: - workflows - workflow_steps - workflow_transitions -
workflow_functions - workflow_buttons
Optional but recommended: - workflow_runs - workflow_run_steps
This structure enables: - Dynamic workflows - Flexible branching logic -
Reusable functions - UI-driven execution
+192
View File
@@ -0,0 +1,192 @@
# Workflow Engine -- Execution Model (Explanation)
## Purpose
This document explains how the workflow engine operates at runtime: -
how steps are executed - how data flows between steps - how decisions
determine the next step
------------------------------------------------------------------------
## High-Level Flow
1. User triggers a workflow (e.g., button)
2. Engine loads the workflow and its start step
3. Engine creates an empty context object
4. Steps are executed sequentially
5. Each step may branch to another step based on its result
6. Execution ends when no next step is defined
------------------------------------------------------------------------
## Core Components
### 1. Workflow
Defines: - name - start step
### 2. Step
Defines: - function to execute - input mapping - parameters - output key
### 3. Function
Implements the actual logic: - UI interaction (dialog, selection) -
system action (call, camera, scan) - computation
### 4. Transition
Maps a result to the next step.
### 5. Context
Shared data store during execution.
------------------------------------------------------------------------
## Context Model
The context is a key-value store:
``` json
{
"SelectedContact": "Peter",
"Answer": "YES"
}
```
Rules: - Steps write results using `output_key` - Later steps read
values via `input_mapping`
------------------------------------------------------------------------
## Step Execution Lifecycle
For each step:
1. Resolve input values from context
2. Merge with static parameters
3. Call function
4. Receive result:
- `value`
- `result_code`
5. Store result in context
6. Determine next step via transitions
------------------------------------------------------------------------
## Result Handling
A function returns:
``` json
{
"value": "...",
"result_code": "YES"
}
```
The `result_code` is used for branching.
Examples: - YES / NO - OK / CANCEL - PETER / NOT_PETER - ERROR
------------------------------------------------------------------------
## Transition Resolution
Order: 1. Find transition with matching `result_code` 2. If none → use
`is_default` 3. If none → end workflow
------------------------------------------------------------------------
## Example Walkthrough
### Scenario: Call Workflow
#### Step 1 -- Select Contact
Result:
``` json
{
"value": "Peter",
"result_code": "PETER"
}
```
→ Next step: Ask confirmation
------------------------------------------------------------------------
#### Step 2 -- Ask Confirmation
User selects YES
``` json
{
"value": true,
"result_code": "YES"
}
```
→ Next step: Call
------------------------------------------------------------------------
#### Step 3 -- Call Contact
Uses:
``` json
SelectedContact = "Peter"
```
→ Executes call → End
------------------------------------------------------------------------
## Error Handling
If a function fails: - return `result_code = ERROR` - transition can
handle it - or workflow stops
Optional: - retry step - fallback step - log error
------------------------------------------------------------------------
## Persistence (Optional)
During execution: - store workflow_runs - store workflow_run_steps
Benefits: - debugging - history - resume execution
------------------------------------------------------------------------
## Design Principles
- Data-driven (no hardcoded flows)
- Extensible (new functions)
- Reusable (functions used in multiple workflows)
- UI-independent (engine separated from UI)
------------------------------------------------------------------------
## Summary
Execution is a loop:
``` text
Step → Function → Result → Transition → Next Step
```
Data flows via:
``` text
Context (key-value store)
```
Control flow is defined by:
``` text
Transitions (result_code → next step)
```