From f6f999a1532eec6beed565aef3cdf009e4834a2d Mon Sep 17 00:00:00 2001 From: Alberto Date: Tue, 7 Jul 2026 09:38:39 +0200 Subject: [PATCH] Code revision --- README.md | 356 ++++++++++++++++++ .../example/ichwillheim/CallOverlayService.kt | 328 ++++++++++++++++ .../com/example/ichwillheim/ContentScreen.kt | 130 +++++++ .../com/example/ichwillheim/HomeScreen.kt | 196 ++++++++++ .../ichwillheim/LauncherSettingsScreen.kt | 145 +++++++ .../java/com/example/ichwillheim/PinScreen.kt | 84 +++++ .../example/ichwillheim/PreCallStepsScreen.kt | 53 +++ .../ichwillheim/RemoteConfigRepository.kt | 188 +++++++++ .../example/ichwillheim/SettingsRepository.kt | 31 ++ .../ichwillheim/SpeakerChoiceScreen.kt | 89 +++++ .../com/example/ichwillheim/WorkflowEngine.kt | 46 +++ .../com/example/ichwillheim/WorkflowModels.kt | 35 ++ app/src/main/res/drawable/speakeroff.png | Bin 0 -> 5693 bytes app/src/main/res/drawable/speakeron.png | Bin 0 -> 5936 bytes app/src/main/res/values-de/strings.xml | 114 ++++++ app/src/main/res/values-es/strings.xml | 114 ++++++ speakeroff.svg | 36 ++ speakeron.svg | 60 +++ workflow_engine_design.md | 210 +++++++++++ workflow_engine_execution.md | 192 ++++++++++ 20 files changed, 2407 insertions(+) create mode 100644 README.md create mode 100644 app/src/main/java/com/example/ichwillheim/CallOverlayService.kt create mode 100644 app/src/main/java/com/example/ichwillheim/ContentScreen.kt create mode 100644 app/src/main/java/com/example/ichwillheim/HomeScreen.kt create mode 100644 app/src/main/java/com/example/ichwillheim/LauncherSettingsScreen.kt create mode 100644 app/src/main/java/com/example/ichwillheim/PinScreen.kt create mode 100644 app/src/main/java/com/example/ichwillheim/PreCallStepsScreen.kt create mode 100644 app/src/main/java/com/example/ichwillheim/RemoteConfigRepository.kt create mode 100644 app/src/main/java/com/example/ichwillheim/SettingsRepository.kt create mode 100644 app/src/main/java/com/example/ichwillheim/SpeakerChoiceScreen.kt create mode 100644 app/src/main/java/com/example/ichwillheim/WorkflowEngine.kt create mode 100644 app/src/main/java/com/example/ichwillheim/WorkflowModels.kt create mode 100755 app/src/main/res/drawable/speakeroff.png create mode 100755 app/src/main/res/drawable/speakeron.png create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 speakeroff.svg create mode 100644 speakeron.svg create mode 100644 workflow_engine_design.md create mode 100644 workflow_engine_execution.md 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 0000000000000000000000000000000000000000..dcd9b30003303ef50560b806580223f4191e6315 GIT binary patch literal 5693 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 + + + 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) +```