diff --git a/README.md b/README.md new file mode 100644 index 0000000..a36926c --- /dev/null +++ b/README.md @@ -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 diff --git a/app/src/main/java/com/example/ichwillheim/CallOverlayService.kt b/app/src/main/java/com/example/ichwillheim/CallOverlayService.kt new file mode 100644 index 0000000..7a09301 --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/CallOverlayService.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/ichwillheim/ContentScreen.kt b/app/src/main/java/com/example/ichwillheim/ContentScreen.kt new file mode 100644 index 0000000..d767af6 --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/ContentScreen.kt @@ -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, + 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) + } + } + } +} diff --git a/app/src/main/java/com/example/ichwillheim/HomeScreen.kt b/app/src/main/java/com/example/ichwillheim/HomeScreen.kt new file mode 100644 index 0000000..441dc2d --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/HomeScreen.kt @@ -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 = emptyList(), + gridSize: Int = 6, + onStartWorkflow: (Workflow) -> Unit = {}, + modifier: Modifier = Modifier +) { + val cols = 2 + + // The caregiver may place buttons beyond the device's gridSize setting. + // Instead of hiding them, auto-extend to the row that contains the highest button. + val highestCell = workflows + .filter { it.homepagePosition != null } + .maxOfOrNull { w -> w.homepagePosition!! + (w.buttonSpan - 1) } ?: 0 + val rowsFromContent = if (highestCell > 0) (highestCell + 1) / 2 else 0 + val rows = maxOf(gridSize / cols, rowsFromContent) + val effectiveGridSize = rows * cols + + // Build a flat array of cells. + // span=2 means the button spans the full row (both columns). + // We only allow span=2 when the button starts at column 0 (odd position in 1-indexed). + val cells = Array(effectiveGridSize) { GridCell(null) } + workflows + .filter { it.homepagePosition != null && it.homepagePosition in 1..effectiveGridSize } + .forEach { wf -> + val pos = wf.homepagePosition!! - 1 + if (cells[pos].isConsumed) return@forEach + val span = if (wf.buttonSpan == 2 && pos % cols == 0 && pos + 1 < effectiveGridSize) 2 else 1 + cells[pos] = GridCell(wf.copy(buttonSpan = span)) + if (span == 2) cells[pos + 1] = GridCell(null, isConsumed = true) + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Clock: 1 share of vertical space + ClockSection(modifier = Modifier.weight(1f).fillMaxWidth()) + + // Grid: 3 shares — about 75% of the screen + Column( + modifier = Modifier.weight(3f).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + for (row in 0 until rows) { + val rowStart = row * cols + val firstCell = cells[rowStart] + + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (firstCell.workflow?.buttonSpan == 2) { + // Span-2: button fills the entire row + WorkflowButton( + label = firstCell.workflow.name, + onClick = { onStartWorkflow(firstCell.workflow) }, + modifier = Modifier.fillMaxWidth().fillMaxHeight() + ) + } else { + // Normal: two side-by-side cells + for (col in 0 until cols) { + val cell = cells[rowStart + col] + when { + cell.isConsumed -> { /* skip — covered by a span-2 button */ } + cell.workflow != null -> + WorkflowButton( + label = cell.workflow.name, + onClick = { onStartWorkflow(cell.workflow) }, + modifier = Modifier.weight(1f).fillMaxHeight() + ) + else -> + EmptyGridCell(modifier = Modifier.weight(1f).fillMaxHeight()) + } + } + } + } + } + } + + + + + IconButton( + onClick = onOpenSettings, + modifier = Modifier.padding(top = 4.dp).size(44.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.cd_caregiver_settings) + ) + } + } +} + +@Composable +fun WorkflowButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = onClick, + shape = RoundedCornerShape(16.dp), + modifier = modifier + ) { + Text( + text = label, + fontSize = 22.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun EmptyGridCell(modifier: Modifier = Modifier) { + Box(modifier = modifier) +} + +@Composable +fun ClockSection(modifier: Modifier = Modifier) { + var time by remember { mutableStateOf("") } + var day by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + while (true) { + val calendar = Calendar.getInstance() + val hour = calendar.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val minute = calendar.get(Calendar.MINUTE).toString().padStart(2, '0') + time = "$hour:$minute" + day = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: "" + delay(1000) + } + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = time, fontSize = 72.sp, fontWeight = FontWeight.Light) + Text( + text = day, + fontSize = 28.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/src/main/java/com/example/ichwillheim/LauncherSettingsScreen.kt b/app/src/main/java/com/example/ichwillheim/LauncherSettingsScreen.kt new file mode 100644 index 0000000..358d20a --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/LauncherSettingsScreen.kt @@ -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)) + } + } + } +} + diff --git a/app/src/main/java/com/example/ichwillheim/PinScreen.kt b/app/src/main/java/com/example/ichwillheim/PinScreen.kt new file mode 100644 index 0000000..e3352d3 --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/PinScreen.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt b/app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt new file mode 100644 index 0000000..e08bb98 --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt @@ -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) } + } + } +} diff --git a/app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt b/app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt new file mode 100644 index 0000000..8d94823 --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt @@ -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(val data: List) + +@Serializable +private data class NbWorkflow( + val id: Long, + val name: String, + val icon: String? = null, + @SerialName("homepage_position") val homepagePosition: Int? = null, + @SerialName("button_span") val buttonSpan: Int? = null, + @SerialName("workflow_steps") val steps: List = emptyList() +) + +@Serializable +private data class NbStep( + val id: Long, + val name: String, + val function: NbFunction? = null, + @SerialName("sort_order") val sortOrder: Int? = null, + @SerialName("parameter_json") val parameterJson: String? = null, + @SerialName("if_result_code") val ifResultCode: String? = null, + @SerialName("then_step_key") val thenStepKey: Long? = null, + @SerialName("else_step_key") val elseStepKey: Long? = null, + @SerialName("content_block") val contentBlocks: List = emptyList() +) + +@Serializable +private data class NbFunction( + val id: Long, + @SerialName("function_id") val functionKey: String +) + +@Serializable +private data class NbContentBlock( + val id: Long, + val type: String, + val value: String, + @SerialName("sort_order") val sortOrder: Int? = null, + @SerialName("result_code") val resultCode: String? = null, +) + +@Serializable +private data class NbRef(val id: Long) + +@Serializable +private data class NbDeviceRef(val id: Long) + +// --- Mapping from DTOs to domain models --- + +private fun NbWorkflow.toDomain() = Workflow( + id = id, + name = name, + icon = icon, + homepagePosition = homepagePosition, + buttonSpan = buttonSpan ?: 1, + steps = steps.map { it.toDomain() }, +) + +private fun NbStep.toDomain() = WorkflowStep( + id = id, + name = name, + functionKey = function?.functionKey ?: "", + sortOrder = sortOrder ?: Int.MAX_VALUE, + parameterJson = parameterJson, + ifResultCode = ifResultCode, + thenStepId = thenStepKey, + elseStepId = elseStepKey, + contentBlocks = contentBlocks.sortedBy { it.sortOrder ?: 0 }.map { it.toDomain() } +) + +private fun NbContentBlock.toDomain() = ContentBlock( + id = id, + type = type, + value = value, + position = sortOrder ?: 0, + resultCode = resultCode, +) + +// --- Repository --- + +object RemoteConfigRepository { + + private val client = HttpClient(Android) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + + suspend fun fetchWorkflows(deviceId: String): List? { + return try { + Log.d("RemoteConfig", "Fetching workflows for device: $deviceId") + val devicePk = resolveDevicePk(deviceId) ?: run { + Log.e("RemoteConfig", "Device not found: $deviceId") + return null + } + val raw = client.get( + "${BuildConfig.NOCOBASE_URL}/api/workflow:list" + ) { + header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}") + url { + parameters.append("filter", """{"device_key":"$devicePk"}""") + parameters.appendAll( + "appends[]", listOf( + "workflow_steps", + "workflow_steps.function", + "workflow_steps.content_block" + ) + ) + } + }.bodyAsText() + Log.d("RemoteConfig", "Raw response: $raw") + val response = Json { ignoreUnknownKeys = true } + .decodeFromString>(raw) + val workflows = response.data.map { it.toDomain() } + Log.d("RemoteConfig", "Fetched ${workflows.size} workflow(s)") + workflows + } catch (e: Exception) { + Log.e("RemoteConfig", "Failed to fetch workflows: ${e.message}") + null + } + } + + suspend fun logEvent( + deviceId: String, + workflowName: String, + functionId: String, + resultCode: String?, + durationSeconds: Double + ) { + try { + client.post("${BuildConfig.NOCOBASE_URL}/api/event_log:create") { + header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}") + contentType(ContentType.Application.Json) + setBody(EventLogPayload(deviceId, workflowName, functionId, resultCode, durationSeconds)) + } + } catch (e: Exception) { + Log.e("RemoteConfig", "Failed to log event: ${e.message}") + } + } + + private suspend fun resolveDevicePk(deviceId: String): Long? { + return try { + val raw = client.get( + "${BuildConfig.NOCOBASE_URL}/api/devices:list" + ) { + header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}") + url { parameters.append("filter", """{"device_id":"$deviceId"}""") } + }.bodyAsText() + Json { ignoreUnknownKeys = true } + .decodeFromString>(raw) + .data.firstOrNull()?.id + } catch (e: Exception) { + Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}") + null + } + } +} diff --git a/app/src/main/java/com/example/ichwillheim/SettingsRepository.kt b/app/src/main/java/com/example/ichwillheim/SettingsRepository.kt new file mode 100644 index 0000000..fce75ad --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/SettingsRepository.kt @@ -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 = 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() + } +} diff --git a/app/src/main/java/com/example/ichwillheim/SpeakerChoiceScreen.kt b/app/src/main/java/com/example/ichwillheim/SpeakerChoiceScreen.kt new file mode 100644 index 0000000..5ee52a0 --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/SpeakerChoiceScreen.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/example/ichwillheim/WorkflowEngine.kt b/app/src/main/java/com/example/ichwillheim/WorkflowEngine.kt new file mode 100644 index 0000000..8a0ed5f --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/WorkflowEngine.kt @@ -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 = 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 + ) + } + } +} diff --git a/app/src/main/java/com/example/ichwillheim/WorkflowModels.kt b/app/src/main/java/com/example/ichwillheim/WorkflowModels.kt new file mode 100644 index 0000000..317c789 --- /dev/null +++ b/app/src/main/java/com/example/ichwillheim/WorkflowModels.kt @@ -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 = 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 = emptyList(), +) diff --git a/app/src/main/res/drawable/speakeroff.png b/app/src/main/res/drawable/speakeroff.png new file mode 100755 index 0000000..dcd9b30 Binary files /dev/null and b/app/src/main/res/drawable/speakeroff.png differ diff --git a/app/src/main/res/drawable/speakeron.png b/app/src/main/res/drawable/speakeron.png new file mode 100755 index 0000000..de99df0 Binary files /dev/null and b/app/src/main/res/drawable/speakeron.png differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..d4498c0 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,114 @@ + + + + Abbrechen + Fertig + Speichern + Bestätigen + Weiter + Schließen + Anrufen + Einstellungen öffnen + Kontakt hinzufügen + Guide hinzufügen + Schritt hinzufügen + Hilfe-Bildschirm bearbeiten + + + Zurück + Weiter + Zurück + Einstellungen + %1$s bearbeiten + %1$s löschen + Schritte bearbeiten + Guide löschen + Schritt bearbeiten + Schritt löschen + + + Sie haben neue Nachrichten + Kontakte + Nachrichten + Kamera + Foto %1$d + + + Wen möchten Sie anrufen? + + + Einstellungsmenü + Einstellungen + Telefoneinstellungen + + + Einstellungszugang + PIN + Falscher PIN + + + Kontakte bearbeiten + Sprache + Systemsprache + Gerät + Geräte-ID + Ablauf vor dem Anruf + Wählen Sie, welche Schritte vor jedem Anruf angezeigt werden. + Anruf-Overlay + Overlay während des Anrufs + Erinnerungen + Wichtige Informationen vor dem Anruf + Hilfe anbieten + Fragen ob der Patient Hilfe benötigt + Anruf bestätigen + Anruf bestätigen lassen + Lautsprecherwahl + Normal- oder Lautsprecheranruf wählen + + + Berechtigung erforderlich + Um die Anruf-Erinnerung anzuzeigen, erteilen Sie bitte die Berechtigung \"Über anderen Apps anzeigen\" in den Einstellungen. + + + Kontakt hinzufügen + Kontakt bearbeiten + Name + Telefonnummer + Erinnerungsnachricht (optional) + + + Bevor Sie %1$s anrufen + Möchten Sie %1$s anrufen? + Keine Hilfe verfügbar. + Keine Schritte vorhanden. + %1$d / %2$d + + + Hilfe-Bildschirm + Titel + Untertitel (optional) + Guides + Guide hinzufügen + Guide umbenennen + Schritt hinzufügen + Schritt bearbeiten + Text + + + Normaler Anruf + Lautsprecher + Auflegen + Lautsprecher umschalten + + + Telefon-App nicht gefunden + Nachrichten-App nicht gefunden + Kamera-App nicht gefunden + Anrufberechtigung fehlt + + + + 1 Schritt + %d Schritte + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..8b02f6d --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,114 @@ + + + + Cancelar + Listo + Guardar + Confirmar + Siguiente + Cerrar + Llamar + Abrir ajustes + Añadir contacto + Añadir guía + Añadir paso + Editar pantalla de ayuda + + + Anterior + Siguiente + Atrás + Ajustes del cuidador + Editar %1$s + Eliminar %1$s + Editar pasos + Eliminar guía + Editar paso + Eliminar paso + + + Tiene mensajes nuevos + Contactos + Mensajes + Cámara + Foto %1$d + + + ¿A quién desea llamar? + + + Menú del cuidador + Ajustes del lanzador + Ajustes del teléfono + + + Acceso del cuidador + PIN + PIN incorrecto + + + Editar contactos + Idioma + Idioma del sistema + Dispositivo + ID de dispositivo + Flujo de llamada + Elija qué pasos se muestran antes de cada llamada. + Superposición de llamada + Superposición durante la llamada + Recordatorios + Información importante antes de la llamada + Ofrecer ayuda + Preguntar si el paciente necesita ayuda + Confirmar llamada + Pedir al paciente que confirme la llamada + Elección de altavoz + Elegir entre llamada normal o con altavoz + + + Permiso requerido + Para mostrar la superposición de llamada, conceda el permiso \"Mostrar sobre otras apps\" en los ajustes. + + + Añadir contacto + Editar contacto + Nombre + Número de teléfono + Mensaje recordatorio (opcional) + + + Antes de llamar a %1$s + ¿Desea llamar a %1$s? + No hay ayuda disponible. + No hay pasos disponibles. + %1$d / %2$d + + + Pantalla de ayuda + Título + Subtítulo (opcional) + Guías + Añadir guía + Renombrar guía + Añadir paso + Editar paso + Texto + + + Llamada normal + Altavoz + Colgar + Alternar altavoz + + + Aplicación de teléfono no encontrada + Aplicación de mensajes no encontrada + Aplicación de cámara no encontrada + Permiso de llamada faltante + + + + 1 paso + %d pasos + + diff --git a/speakeroff.svg b/speakeroff.svg new file mode 100644 index 0000000..3e9946b --- /dev/null +++ b/speakeroff.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/speakeron.svg b/speakeron.svg new file mode 100644 index 0000000..30df36b --- /dev/null +++ b/speakeron.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/workflow_engine_design.md b/workflow_engine_design.md new file mode 100644 index 0000000..f685dbf --- /dev/null +++ b/workflow_engine_design.md @@ -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 diff --git a/workflow_engine_execution.md b/workflow_engine_execution.md new file mode 100644 index 0000000..ab0cb09 --- /dev/null +++ b/workflow_engine_execution.md @@ -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) +```