Code revision
This commit is contained in:
@@ -0,0 +1,356 @@
|
|||||||
|
# IchWillHeim
|
||||||
|
|
||||||
|
A dementia-friendly Android home screen launcher. The patient sees a locked-down, simple screen. Caregivers configure everything remotely via a web app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product architecture
|
||||||
|
|
||||||
|
The product is split into two distinct parts:
|
||||||
|
|
||||||
|
**1. Android launcher (this app)**
|
||||||
|
The only thing installed on the patient's device. It fetches its configuration from NocoBase on startup and renders a simple, locked-down home screen. No workflow configuration happens on the device.
|
||||||
|
|
||||||
|
**2. Caregiver web app**
|
||||||
|
Where caregivers manage everything: patients, devices, contacts, and workflows. Built in two phases:
|
||||||
|
- **Near term:** NocoBase's interface builder — Table, Form, Grid Card, and Details blocks are sufficient to build a functional caregiver dashboard with no frontend code. Caregivers can add/edit/remove workflows and contacts directly.
|
||||||
|
- **Long term:** a custom web app (and eventually mobile) with a proper onboarding questionnaire (ask what the patient needs → generate the first workflows automatically) plus a manual editor for ongoing changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings on the device
|
||||||
|
|
||||||
|
### Patient-accessible settings
|
||||||
|
Simple, non-technical controls the patient can reach directly from the home screen:
|
||||||
|
- Font / button size
|
||||||
|
- Screen brightness
|
||||||
|
|
||||||
|
### PIN-locked advanced settings
|
||||||
|
Accessible via long-press (PIN-protected). The access screen is labelled **Settings Access** and the menu **Settings Menu**:
|
||||||
|
- **Device ID** — the identifier used to fetch this device's config from NocoBase
|
||||||
|
- **Language** — in-app language picker
|
||||||
|
- **Phone Settings** — opens the Android system settings (for Wi-Fi, accessibility, etc.)
|
||||||
|
|
||||||
|
Workflow configuration, home screen grid layout, and help screen editing are never done on the device — all managed via the web app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the app works
|
||||||
|
|
||||||
|
### From the patient's perspective
|
||||||
|
|
||||||
|
The patient sees one screen with large buttons driven by workflows configured remotely. Pressing Back does nothing. Pressing Home always returns to this screen. No other apps are accessible.
|
||||||
|
|
||||||
|
### From the caregiver's perspective
|
||||||
|
|
||||||
|
Caregivers manage the patient's experience entirely through the web app (NocoBase or future custom app). On the device, they can access PIN-locked settings for device ID and language. They never need to touch workflow config on the device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Home screen grid
|
||||||
|
|
||||||
|
The home screen displays workflow buttons in a fixed 2-column grid. The caregiver sets `homepage_position` (1-indexed, left-to-right, top-to-bottom) and `button_span` (1 = one cell, 2 = wide / full row) for each workflow via the web app.
|
||||||
|
|
||||||
|
**Grid rendering rules (same logic in Android and web preview):**
|
||||||
|
|
||||||
|
1. Build a flat array of cells (size = effective grid size).
|
||||||
|
2. For each workflow sorted by position: place it at `position - 1`. If `button_span == 2` AND the button starts in the left column (even index) AND there is a next cell, mark the next cell as "consumed" so nothing else renders there.
|
||||||
|
3. If a `span-2` button starts in the right column, it is silently downgraded to `span-1` (would overflow the row).
|
||||||
|
4. **Auto-extend**: the configured `gridSize` (6 or 8, set in caregiver settings) is a minimum. If a workflow's position+span exceeds it, the grid grows to fit — `effectiveGridSize = max(configuredRows, rowsNeededByContent) × 2`.
|
||||||
|
|
||||||
|
The same `buildGridCells` algorithm runs in `HomeScreen.kt` (Android) and `Workflows.tsx` (web preview), so what the caregiver sees in the browser is exactly what the patient sees on their phone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Every button on the home screen triggers a **workflow** — a sequence of steps. Each step calls one function (show a screen, place a call, etc.) and returns a result code. Steps execute in `sort_order` ascending; the engine automatically advances to the next step unless an `if` step redirects the flow.
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 (sort=1) → Step 2 (sort=2) → if step → yes: Step 3 / no: Step 4 → …
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything is data-driven. New buttons, screens, and branching logic are configured in the web app with no code changes.
|
||||||
|
|
||||||
|
### Step types
|
||||||
|
|
||||||
|
| function_id | behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `show_call_confirm` | Shows a confirm/cancel screen. Emits `confirmed`. Cancel always ends the workflow. |
|
||||||
|
| `show_speaker_choice` | Shows speakerphone vs normal choice. Emits `speakerphone` or `normal`. |
|
||||||
|
| `call_contact` | Dials `phone` from `parameter_json`. Reads `speakerphone` from `parameter_json` (bool), falls back to `lastResultCode == "speakerphone"`. |
|
||||||
|
| `show_reminder` | Renders `ContentScreen` with the step's `content_block` list. Advances with `null` when the patient taps the button. |
|
||||||
|
| `show_tutorial` | Same as `show_reminder` — mechanically identical. Both render `ContentScreen`. |
|
||||||
|
| `if` | Checks `lastResultCode == if_result_code`. Jumps to `then_step` if true, `else_step` if false. Falls back to next by sort_order if targets not set. |
|
||||||
|
| `label` | No-op. Used as a named jump target for `if` steps. Advances to next step by sort_order. |
|
||||||
|
|
||||||
|
`lastResultCode` is preserved across all steps, so a value set in step 2 is still readable by step 5.
|
||||||
|
|
||||||
|
### Result codes
|
||||||
|
|
||||||
|
| result_code | emitted by |
|
||||||
|
|---|---|
|
||||||
|
| `confirmed` | `show_call_confirm` (user tapped confirm) |
|
||||||
|
| `cancelled` | `show_call_confirm` or `show_speaker_choice` (user tapped cancel) |
|
||||||
|
| `speakerphone` | `show_speaker_choice` |
|
||||||
|
| `normal` | `show_speaker_choice` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NocoBase schema
|
||||||
|
|
||||||
|
**Server:** `https://nocobase.rucki.ch`
|
||||||
|
|
||||||
|
**API notes:**
|
||||||
|
- Filter only on direct fields of the collection — nested relation filters (e.g. `device.device_id`) are not supported. To filter workflows by device, do a two-step lookup: resolve `device_id` → device PK via `/api/devices:list`, then filter workflows by `device_key`.
|
||||||
|
- Appends use `appends[]` query parameter (array format).
|
||||||
|
- When creating relations, always specify the existing FK explicitly in advanced options. If you let NocoBase auto-generate, it creates a second FK column and rows added later won't appear in API responses.
|
||||||
|
- Create belongsTo relations first (they create the FK column), then hasMany reusing the same FK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `device_users` | One row per patient (renamed from `beneficiaries`) |
|
||||||
|
| `devices` | One row per physical device; linked to a device user |
|
||||||
|
| `contacts` | Contacts; linked to device user (not device — shared across all their devices) |
|
||||||
|
| `workflow_function` | Global catalog of executable functions (defined by the app) |
|
||||||
|
| `workflow` | One row per home screen button per device |
|
||||||
|
| `workflow_step` | Steps within a workflow; each calls one function |
|
||||||
|
| `content_block` | Ordered UI content (heading / text / button) for screen steps |
|
||||||
|
| `event_log` | One row per completed workflow step — used by the caregiver dashboard |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `device_users`
|
||||||
|
|
||||||
|
| field | type |
|
||||||
|
|---|---|
|
||||||
|
| `name` | string |
|
||||||
|
| `date_of_birth` | date |
|
||||||
|
| `notes` | text |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `devices`
|
||||||
|
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `device_id` | string | unique; used by the app to fetch its config (underscores, e.g. `redmi_test`) |
|
||||||
|
| `label` | string | human-readable name e.g. "Renate's tablet" |
|
||||||
|
| `beneficiary` | belongsTo → beneficiaries | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `contacts`
|
||||||
|
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | string | |
|
||||||
|
| `phone` | string | |
|
||||||
|
| `photo_url` | text (url) | |
|
||||||
|
| `device_user` | belongsTo → device_users | linked to the person, shared across all their devices |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `workflow_function` — global catalog, defined by the app
|
||||||
|
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `function_id` | string | unique identifier used by the app |
|
||||||
|
| `name` | string | displayed in the web app step editor |
|
||||||
|
| `description` | text | |
|
||||||
|
|
||||||
|
**Current entries:**
|
||||||
|
|
||||||
|
| function_id | name |
|
||||||
|
|---|---|
|
||||||
|
| `show_call_confirm` | Show Call Confirm |
|
||||||
|
| `show_speaker_choice` | Show Speaker Choice |
|
||||||
|
| `call_contact` | Call Contact |
|
||||||
|
| `if` | If / Branch |
|
||||||
|
| `label` | Label |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `workflow` — one per home screen button per device
|
||||||
|
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | string | e.g. "Call Sandro" |
|
||||||
|
| `homepage_position` | integer / null | 1-indexed position in the 2-column grid; null = not on home screen |
|
||||||
|
| `button_span` | integer | 1 = normal (1 cell), 2 = wide (full row); default 1 |
|
||||||
|
| `is_active` | boolean | |
|
||||||
|
| `device` | belongsTo → devices | |
|
||||||
|
| `workflow_steps` | hasMany → workflow_step | fetched via `workflow_steps` append |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `workflow_step` — steps execute in `sort_order` ascending; lowest = start step
|
||||||
|
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | string | |
|
||||||
|
| `sort_order` | integer | lowest value = start step |
|
||||||
|
| `parameter_json` | text | static inputs e.g. `{"phone": "+41768224400"}` |
|
||||||
|
| `function` | belongsTo → workflow_function | app reads `function_id` from the related record |
|
||||||
|
| `if_result_code` | string / null | `if` steps only: the `lastResultCode` value to match |
|
||||||
|
| `then_step` | belongsTo → workflow_step / null | `if` steps only: jump target when condition is true |
|
||||||
|
| `else_step` | belongsTo → workflow_step / null | `if` steps only: jump target when condition is false |
|
||||||
|
| `content_block` | hasMany → content_block | fetched via `workflow_steps.content_block` append |
|
||||||
|
|
||||||
|
> **Unused DB fields** (can be deleted from NocoBase): `output_id`, `input_mapping_json`, `is_active`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `event_log` — one row per completed workflow step
|
||||||
|
|
||||||
|
Written by the Android app fire-and-forget after each step. No UI impact.
|
||||||
|
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `device_id` | string | matches `devices.device_id` |
|
||||||
|
| `workflow_name` | string | name of the running workflow |
|
||||||
|
| `function_id` | string | step type that completed |
|
||||||
|
| `result_code` | string / null | outcome (`confirmed`, `cancelled`, `speakerphone`, `normal`, or null for automatic steps) |
|
||||||
|
| `duration_seconds` | number | time the patient spent on this step |
|
||||||
|
| `created_at` | datetime | auto-set by NocoBase |
|
||||||
|
|
||||||
|
The caregiver web app dashboard groups these rows by device user → workflow → function+outcome to show usage statistics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `content_block` — ordered content elements for screen steps
|
||||||
|
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `type` | select | `heading`, `text`, `image`, `button` |
|
||||||
|
| `value` | text | display text, image URL, or button label |
|
||||||
|
| `sort_order` | integer | render order |
|
||||||
|
| `result_code` | string | `button` type only — emitted when tapped |
|
||||||
|
| `workflow_step` | belongsTo → workflow_step | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Relation map
|
||||||
|
|
||||||
|
| Relation | Type | FK column |
|
||||||
|
|---|---|---|
|
||||||
|
| `devices.device_user` | belongsTo → device_users | `device_users_key` on `devices` |
|
||||||
|
| `contacts.device_user` | belongsTo → device_users | `device_users_key` on `contacts` |
|
||||||
|
| `workflow.device` | belongsTo → devices | `device_key` on `workflow` |
|
||||||
|
| `workflow.workflow_steps` | hasMany → workflow_step | `workflow_key` on `workflow_step` |
|
||||||
|
| `workflow_step.function` | belongsTo → workflow_function | `function_key` on `workflow_step` |
|
||||||
|
| `workflow_step.then_step` | belongsTo → workflow_step | `then_step_key` on `workflow_step` |
|
||||||
|
| `workflow_step.else_step` | belongsTo → workflow_step | `else_step_key` on `workflow_step` |
|
||||||
|
| `workflow_step.content_block` | hasMany → content_block | `content_block_key` on `content_block` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Call Sandro (simple — no speaker choice)
|
||||||
|
|
||||||
|
| sort_order | function_id | parameter_json |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `show_call_confirm` | — |
|
||||||
|
| 2 | `call_contact` | `{"phone": "+41768224400"}` |
|
||||||
|
|
||||||
|
Steps run in order. No transitions, no if steps needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Call Sandro (with speaker choice via if step)
|
||||||
|
|
||||||
|
| sort_order | function_id | parameter_json | if_result_code | then_step | else_step |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `show_call_confirm` | — | — | — | — |
|
||||||
|
| 2 | `show_speaker_choice` | — | — | — | — |
|
||||||
|
| 3 | `if` | — | `speakerphone` | → step 4 | → step 5 |
|
||||||
|
| 4 | `label` | — | — | — | — |
|
||||||
|
| 5 | `call_contact` | `{"phone":"…","speakerphone":true}` | — | — | — |
|
||||||
|
| 6 | `label` | — | — | — | — |
|
||||||
|
| 7 | `call_contact` | `{"phone":"…","speakerphone":false}` | — | — | — |
|
||||||
|
|
||||||
|
Step 3 reads `lastResultCode` from step 2. If it equals `"speakerphone"`, jumps to step 4 (label) → step 5 (call with speaker). Otherwise jumps to step 6 (label) → step 7 (normal call).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/com/example/ichwillheim/
|
||||||
|
│
|
||||||
|
├── MainActivity.kt — entry point, navigation state, workflow runner, call utilities
|
||||||
|
├── HomeScreen.kt — patient home screen (clock, workflow buttons)
|
||||||
|
├── ContentScreen.kt — data-driven content renderer for show_reminder / show_tutorial steps
|
||||||
|
├── PreCallStepsScreen.kt — WorkflowConfirmScreen (show_call_confirm)
|
||||||
|
├── SpeakerChoiceScreen.kt — WorkflowSpeakerChoiceScreen, SpeakerOptionHalf
|
||||||
|
├── PinScreen.kt — PIN entry screen
|
||||||
|
├── LauncherSettingsScreen.kt — SettingsMenuScreen, LauncherSettingsScreen, LanguagePicker
|
||||||
|
├── CallOverlayService.kt — foreground service for call overlay
|
||||||
|
├── WorkflowModels.kt — Workflow, WorkflowStep, ContentBlock
|
||||||
|
├── WorkflowEngine.kt — WorkflowRunState: advance(), param(), start()
|
||||||
|
├── SettingsRepository.kt — AppSettings + SharedPreferences
|
||||||
|
└── RemoteConfigRepository.kt — Ktor HTTP client; fetches workflows from NocoBase
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internationalisation
|
||||||
|
|
||||||
|
All user-facing strings live in `res/values/strings.xml` (English default).
|
||||||
|
|
||||||
|
| Folder | Language |
|
||||||
|
|---|---|
|
||||||
|
| `res/values/` | English (default) |
|
||||||
|
| `res/values-de/` | German |
|
||||||
|
| `res/values-es/` | Spanish |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Why |
|
||||||
|
|---|---|
|
||||||
|
| `CALL_PHONE` | Place calls directly without the dialer |
|
||||||
|
| `READ_PHONE_STATE` | Detect call state (for speakerphone timing) |
|
||||||
|
| `ANSWER_PHONE_CALLS` | Answer incoming calls from the overlay |
|
||||||
|
| `MODIFY_AUDIO_SETTINGS` | Switch to speakerphone |
|
||||||
|
| `SYSTEM_ALERT_WINDOW` | Draw call overlay — must be granted manually |
|
||||||
|
| `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_SPECIAL_USE` | Run CallOverlayService |
|
||||||
|
| `POST_NOTIFICATIONS` | Required on Android 13+ for foreground service notification |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The app authenticates to NocoBase via a `Bearer` token in every request. The token is stored in `local.properties` (never committed) and injected at build time via `BuildConfig`.
|
||||||
|
|
||||||
|
**Before any public release:** replace with a proper auth layer — caregiver accounts, server-side token exchange, per-user data isolation in NocoBase. The database schema does not need to change; only the authentication mechanism on top of it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color palette
|
||||||
|
|
||||||
|
Both the Android launcher and the caregiver web app use the same three-color system:
|
||||||
|
|
||||||
|
| Role | Hex | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| Background / surface | `#FFF6E5` | Screen background, sidebar, cards |
|
||||||
|
| Primary | `#A6CDB2` | Buttons, active nav items, focus rings |
|
||||||
|
| Accent | `#FCAB7E` | Cancel buttons, secondary actions, highlights |
|
||||||
|
| Text | `#2D2D2D` | All foreground text on the above backgrounds |
|
||||||
|
|
||||||
|
All three background colors pass WCAG AA contrast with `#2D2D2D` text (≥ 7:1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideas backlog
|
||||||
|
|
||||||
|
- **Custom caregiver web app** — onboarding questionnaire (generates first workflows) + manual editor; eventually iOS/Android caregiver app
|
||||||
|
- **Unified inbox** — SMS + WhatsApp in one patient-facing screen
|
||||||
|
- **Photo slideshow management** — upload/manage slideshow photos remotely via NocoBase
|
||||||
|
- **Message effectiveness tracking** — log whether reminders led to calls or cancellations
|
||||||
|
- **Caregiver accounts** — multiple caregivers per patient, each with their own login
|
||||||
|
- **Community platform** — caregivers share workflow presets, tips, and experiences
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.telecom.TelecomManager
|
||||||
|
import android.telephony.PhoneStateListener
|
||||||
|
import android.telephony.TelephonyCallback
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CallEnd
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
|
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||||
|
import androidx.savedstate.SavedStateRegistry
|
||||||
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
|
||||||
|
|
||||||
|
const val EXTRA_SPEAKERPHONE = "speakerphone"
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "call_overlay_channel"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
|
class CallOverlayService : Service() {
|
||||||
|
|
||||||
|
private lateinit var windowManager: WindowManager
|
||||||
|
private var overlayView: ComposeView? = null
|
||||||
|
private val lifecycleOwner = ServiceLifecycleOwner()
|
||||||
|
|
||||||
|
// Observed by the Compose content inside the overlay
|
||||||
|
private var speakerphoneOn by mutableStateOf(false)
|
||||||
|
|
||||||
|
// Prevents stopping the service on the initial IDLE state at registration time
|
||||||
|
private var hasBeenOffHook = false
|
||||||
|
|
||||||
|
private var telephonyCallback: TelephonyCallback? = null
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private var legacyPhoneStateListener: PhoneStateListener? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
|
||||||
|
lifecycleOwner.start()
|
||||||
|
startAsForeground()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
speakerphoneOn = intent?.getBooleanExtra(EXTRA_SPEAKERPHONE, false) ?: false
|
||||||
|
showOverlay()
|
||||||
|
monitorCallState()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAsForeground() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "Anruf-Erinnerung", NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("Anruf läuft")
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
|
||||||
|
.build()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showOverlay() {
|
||||||
|
if (overlayView != null) return
|
||||||
|
val params = WindowManager.LayoutParams(
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT,
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT,
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||||
|
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
|
||||||
|
PixelFormat.TRANSLUCENT
|
||||||
|
)
|
||||||
|
val view = ComposeView(this).apply {
|
||||||
|
setViewTreeLifecycleOwner(lifecycleOwner)
|
||||||
|
setViewTreeSavedStateRegistryOwner(lifecycleOwner)
|
||||||
|
setContent {
|
||||||
|
IchWillHeimTheme {
|
||||||
|
CallOverlayContent(
|
||||||
|
speakerphoneOn = speakerphoneOn,
|
||||||
|
onToggleSpeaker = { toggleSpeakerphone() },
|
||||||
|
onHangUp = { hangUp() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
windowManager.addView(view, params)
|
||||||
|
overlayView = view
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleSpeakerphone() {
|
||||||
|
speakerphoneOn = !speakerphoneOn
|
||||||
|
val audio = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
if (speakerphoneOn) setOverlaySpeakerphoneOn(audio) else setOverlaySpeakerphoneOff(audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hangUp() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
val telecom = getSystemService(TELECOM_SERVICE) as TelecomManager
|
||||||
|
try { telecom.endCall() } catch (_: SecurityException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun monitorCallState() {
|
||||||
|
val telephony = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||||
|
override fun onCallStateChanged(state: Int) = handleCallState(state)
|
||||||
|
}
|
||||||
|
telephonyCallback = callback
|
||||||
|
telephony.registerTelephonyCallback(mainExecutor, callback)
|
||||||
|
} else {
|
||||||
|
val listener = object : PhoneStateListener() {
|
||||||
|
override fun onCallStateChanged(state: Int, phoneNumber: String?) =
|
||||||
|
handleCallState(state)
|
||||||
|
}
|
||||||
|
legacyPhoneStateListener = listener
|
||||||
|
telephony.listen(listener, PhoneStateListener.LISTEN_CALL_STATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCallState(state: Int) {
|
||||||
|
when (state) {
|
||||||
|
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||||
|
hasBeenOffHook = true
|
||||||
|
if (speakerphoneOn) {
|
||||||
|
val audio = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
setOverlaySpeakerphoneOn(audio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TelephonyManager.CALL_STATE_IDLE -> {
|
||||||
|
// Guard against the initial IDLE fired at listener registration time
|
||||||
|
if (hasBeenOffHook) stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
val telephony = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
telephonyCallback?.let { telephony.unregisterTelephonyCallback(it) }
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
legacyPhoneStateListener?.let { telephony.listen(it, PhoneStateListener.LISTEN_NONE) }
|
||||||
|
}
|
||||||
|
overlayView?.let { try { windowManager.removeView(it) } catch (_: Exception) { } }
|
||||||
|
overlayView = null
|
||||||
|
lifecycleOwner.stop()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setOverlaySpeakerphoneOn(audioManager: AudioManager) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val speaker = audioManager.availableCommunicationDevices
|
||||||
|
.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
|
||||||
|
if (speaker != null) audioManager.setCommunicationDevice(speaker)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.isSpeakerphoneOn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setOverlaySpeakerphoneOff(audioManager: AudioManager) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManager.clearCommunicationDevice()
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.isSpeakerphoneOn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CallOverlayContent(
|
||||||
|
speakerphoneOn: Boolean,
|
||||||
|
onToggleSpeaker: () -> Unit,
|
||||||
|
onHangUp: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(
|
||||||
|
id = if (speakerphoneOn) R.drawable.speakeron else R.drawable.speakeroff
|
||||||
|
),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.7f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (speakerphoneOn)
|
||||||
|
"Halten Sie das Gerät vor sich"
|
||||||
|
else
|
||||||
|
"Halten Sie das Telefon ans Ohr",
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.2f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 64.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onToggleSpeaker,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(
|
||||||
|
id = if (speakerphoneOn) R.drawable.speakeron else R.drawable.speakeroff
|
||||||
|
),
|
||||||
|
contentDescription = stringResource(R.string.cd_toggle_speakerphone)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.speaker_speakerphone), fontSize = 12.sp, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onHangUp,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.background(Color(0xFFD32F2F), CircleShape)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CallEnd,
|
||||||
|
contentDescription = stringResource(R.string.overlay_hang_up),
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.overlay_hang_up), fontSize = 12.sp, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ServiceLifecycleOwner : SavedStateRegistryOwner {
|
||||||
|
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||||
|
private val savedStateController = SavedStateRegistryController.create(this)
|
||||||
|
|
||||||
|
override val lifecycle: Lifecycle = lifecycleRegistry
|
||||||
|
override val savedStateRegistry: SavedStateRegistry = savedStateController.savedStateRegistry
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
savedStateController.performRestore(null)
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic content screen for data-driven workflow steps.
|
||||||
|
*
|
||||||
|
* Renders a list of ContentBlocks (heading / text / image / button) fetched
|
||||||
|
* from NocoBase. Adding a new content step never requires an app update —
|
||||||
|
* just add blocks in the caregiver web app.
|
||||||
|
*
|
||||||
|
* Block types:
|
||||||
|
* "heading" — large bold title, centred
|
||||||
|
* "text" — body paragraph
|
||||||
|
* "image" — placeholder until Coil is added (TODO)
|
||||||
|
* "button" — full-width tappable button; advances the workflow
|
||||||
|
*
|
||||||
|
* If no "button" block is present a default "Next" button is shown at the bottom.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ContentScreen(
|
||||||
|
blocks: List<ContentBlock>,
|
||||||
|
onAdvance: (String?) -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler { onCancel() }
|
||||||
|
|
||||||
|
val sorted = blocks.sortedBy { it.position }
|
||||||
|
val hasButton = sorted.any { it.type == "button" }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
// Close button — top-right corner
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onCancel) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_close)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
sorted.forEach { block ->
|
||||||
|
when (block.type) {
|
||||||
|
"heading" -> Text(
|
||||||
|
text = block.value,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
"text" -> Text(
|
||||||
|
text = block.value,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
"image" -> {
|
||||||
|
// TODO: replace with AsyncImage once Coil is added to dependencies
|
||||||
|
Text(
|
||||||
|
text = "🖼 ${block.value}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"button" -> {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onAdvance(block.resultCode) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(72.dp)
|
||||||
|
) {
|
||||||
|
Text(block.value, fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if the step has no button block, show a generic "Next" button
|
||||||
|
if (!hasButton) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onAdvance(null) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(72.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_next), fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private data class GridCell(
|
||||||
|
val workflow: Workflow?,
|
||||||
|
val isConsumed: Boolean = false // true when occupied by the second cell of a span-2 button
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
workflows: List<Workflow> = emptyList(),
|
||||||
|
gridSize: Int = 6,
|
||||||
|
onStartWorkflow: (Workflow) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val cols = 2
|
||||||
|
|
||||||
|
// The caregiver may place buttons beyond the device's gridSize setting.
|
||||||
|
// Instead of hiding them, auto-extend to the row that contains the highest button.
|
||||||
|
val highestCell = workflows
|
||||||
|
.filter { it.homepagePosition != null }
|
||||||
|
.maxOfOrNull { w -> w.homepagePosition!! + (w.buttonSpan - 1) } ?: 0
|
||||||
|
val rowsFromContent = if (highestCell > 0) (highestCell + 1) / 2 else 0
|
||||||
|
val rows = maxOf(gridSize / cols, rowsFromContent)
|
||||||
|
val effectiveGridSize = rows * cols
|
||||||
|
|
||||||
|
// Build a flat array of cells.
|
||||||
|
// span=2 means the button spans the full row (both columns).
|
||||||
|
// We only allow span=2 when the button starts at column 0 (odd position in 1-indexed).
|
||||||
|
val cells = Array<GridCell>(effectiveGridSize) { GridCell(null) }
|
||||||
|
workflows
|
||||||
|
.filter { it.homepagePosition != null && it.homepagePosition in 1..effectiveGridSize }
|
||||||
|
.forEach { wf ->
|
||||||
|
val pos = wf.homepagePosition!! - 1
|
||||||
|
if (cells[pos].isConsumed) return@forEach
|
||||||
|
val span = if (wf.buttonSpan == 2 && pos % cols == 0 && pos + 1 < effectiveGridSize) 2 else 1
|
||||||
|
cells[pos] = GridCell(wf.copy(buttonSpan = span))
|
||||||
|
if (span == 2) cells[pos + 1] = GridCell(null, isConsumed = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Clock: 1 share of vertical space
|
||||||
|
ClockSection(modifier = Modifier.weight(1f).fillMaxWidth())
|
||||||
|
|
||||||
|
// Grid: 3 shares — about 75% of the screen
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(3f).fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
for (row in 0 until rows) {
|
||||||
|
val rowStart = row * cols
|
||||||
|
val firstCell = cells[rowStart]
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
if (firstCell.workflow?.buttonSpan == 2) {
|
||||||
|
// Span-2: button fills the entire row
|
||||||
|
WorkflowButton(
|
||||||
|
label = firstCell.workflow.name,
|
||||||
|
onClick = { onStartWorkflow(firstCell.workflow) },
|
||||||
|
modifier = Modifier.fillMaxWidth().fillMaxHeight()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Normal: two side-by-side cells
|
||||||
|
for (col in 0 until cols) {
|
||||||
|
val cell = cells[rowStart + col]
|
||||||
|
when {
|
||||||
|
cell.isConsumed -> { /* skip — covered by a span-2 button */ }
|
||||||
|
cell.workflow != null ->
|
||||||
|
WorkflowButton(
|
||||||
|
label = cell.workflow.name,
|
||||||
|
onClick = { onStartWorkflow(cell.workflow) },
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight()
|
||||||
|
)
|
||||||
|
else ->
|
||||||
|
EmptyGridCell(modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
modifier = Modifier.padding(top = 4.dp).size(44.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = stringResource(R.string.cd_caregiver_settings)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WorkflowButton(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyGridCell(modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClockSection(modifier: Modifier = Modifier) {
|
||||||
|
var time by remember { mutableStateOf("") }
|
||||||
|
var day by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
val hour = calendar.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
|
val minute = calendar.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
|
time = "$hour:$minute"
|
||||||
|
day = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: ""
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(text = time, fontSize = 72.sp, fontWeight = FontWeight.Light)
|
||||||
|
Text(
|
||||||
|
text = day,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsMenuScreen(
|
||||||
|
onLauncherSettings: () -> Unit,
|
||||||
|
onPhoneSettings: () -> Unit,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler { onClose() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.caregiver_menu_title), style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onLauncherSettings,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(80.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.launcher_settings_title), fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onPhoneSettings,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(80.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.caregiver_menu_phone_settings), fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
TextButton(onClick = onClose) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LauncherSettingsScreen(
|
||||||
|
settings: AppSettings,
|
||||||
|
onSettingsChanged: (AppSettings) -> Unit,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler { onClose() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.launcher_settings_title), style = MaterialTheme.typography.headlineSmall)
|
||||||
|
TextButton(onClick = onClose) { Text(stringResource(R.string.action_done)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_device_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.deviceId,
|
||||||
|
onValueChange = { onSettingsChanged(settings.copy(deviceId = it.trim())) },
|
||||||
|
label = { Text(stringResource(R.string.settings_device_id)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_language_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
LanguagePicker()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguagePicker() {
|
||||||
|
val currentTag = AppCompatDelegate.getApplicationLocales().let {
|
||||||
|
if (it.isEmpty) "" else it[0]?.language ?: ""
|
||||||
|
}
|
||||||
|
val systemLabel = stringResource(R.string.settings_language_system)
|
||||||
|
val options = listOf(
|
||||||
|
"" to systemLabel,
|
||||||
|
"en" to "English",
|
||||||
|
"de" to "Deutsch",
|
||||||
|
"es" to "Español",
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
options.forEach { (tag, name) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
AppCompatDelegate.setApplicationLocales(
|
||||||
|
if (tag.isEmpty()) LocaleListCompat.getEmptyLocaleList()
|
||||||
|
else LocaleListCompat.forLanguageTags(tag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(selected = currentTag == tag, onClick = null)
|
||||||
|
Text(name, fontSize = 15.sp, modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
const val SETTINGS_PIN = "1234"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PinScreen(
|
||||||
|
onCorrectPin: () -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler { onCancel() }
|
||||||
|
|
||||||
|
var pin by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.pin_screen_title), style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pin,
|
||||||
|
onValueChange = { input ->
|
||||||
|
if (input.length <= 4 && input.all { it.isDigit() }) {
|
||||||
|
pin = input
|
||||||
|
error = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.field_pin)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||||
|
isError = error,
|
||||||
|
supportingText = { if (error) Text(stringResource(R.string.pin_error_incorrect)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
if (pin == SETTINGS_PIN) {
|
||||||
|
onCorrectPin()
|
||||||
|
} else {
|
||||||
|
error = true
|
||||||
|
pin = ""
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.action_confirm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WorkflowConfirmScreen(
|
||||||
|
title: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler { onCancel() }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onCancel,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
||||||
|
modifier = Modifier.weight(1f).height(72.dp)
|
||||||
|
) { Text(stringResource(R.string.action_cancel), fontSize = 18.sp) }
|
||||||
|
Button(
|
||||||
|
onClick = onConfirm,
|
||||||
|
modifier = Modifier.weight(1f).height(72.dp)
|
||||||
|
) { Text(stringResource(R.string.action_call), fontSize = 18.sp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.client.engine.android.Android
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
// --- DTOs ---
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class EventLogPayload(
|
||||||
|
@SerialName("device_id") val deviceId: String,
|
||||||
|
@SerialName("workflow_name") val workflowName: String,
|
||||||
|
@SerialName("function_id") val functionId: String,
|
||||||
|
@SerialName("result_code") val resultCode: String? = null,
|
||||||
|
@SerialName("duration_seconds") val durationSeconds: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- NocoBase response DTOs ---
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbListResponse<T>(val data: List<T>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbWorkflow(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val icon: String? = null,
|
||||||
|
@SerialName("homepage_position") val homepagePosition: Int? = null,
|
||||||
|
@SerialName("button_span") val buttonSpan: Int? = null,
|
||||||
|
@SerialName("workflow_steps") val steps: List<NbStep> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbStep(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val function: NbFunction? = null,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||||
|
@SerialName("parameter_json") val parameterJson: String? = null,
|
||||||
|
@SerialName("if_result_code") val ifResultCode: String? = null,
|
||||||
|
@SerialName("then_step_key") val thenStepKey: Long? = null,
|
||||||
|
@SerialName("else_step_key") val elseStepKey: Long? = null,
|
||||||
|
@SerialName("content_block") val contentBlocks: List<NbContentBlock> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbFunction(
|
||||||
|
val id: Long,
|
||||||
|
@SerialName("function_id") val functionKey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbContentBlock(
|
||||||
|
val id: Long,
|
||||||
|
val type: String,
|
||||||
|
val value: String,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||||
|
@SerialName("result_code") val resultCode: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbRef(val id: Long)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbDeviceRef(val id: Long)
|
||||||
|
|
||||||
|
// --- Mapping from DTOs to domain models ---
|
||||||
|
|
||||||
|
private fun NbWorkflow.toDomain() = Workflow(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
icon = icon,
|
||||||
|
homepagePosition = homepagePosition,
|
||||||
|
buttonSpan = buttonSpan ?: 1,
|
||||||
|
steps = steps.map { it.toDomain() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NbStep.toDomain() = WorkflowStep(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
functionKey = function?.functionKey ?: "",
|
||||||
|
sortOrder = sortOrder ?: Int.MAX_VALUE,
|
||||||
|
parameterJson = parameterJson,
|
||||||
|
ifResultCode = ifResultCode,
|
||||||
|
thenStepId = thenStepKey,
|
||||||
|
elseStepId = elseStepKey,
|
||||||
|
contentBlocks = contentBlocks.sortedBy { it.sortOrder ?: 0 }.map { it.toDomain() }
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NbContentBlock.toDomain() = ContentBlock(
|
||||||
|
id = id,
|
||||||
|
type = type,
|
||||||
|
value = value,
|
||||||
|
position = sortOrder ?: 0,
|
||||||
|
resultCode = resultCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Repository ---
|
||||||
|
|
||||||
|
object RemoteConfigRepository {
|
||||||
|
|
||||||
|
private val client = HttpClient(Android) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchWorkflows(deviceId: String): List<Workflow>? {
|
||||||
|
return try {
|
||||||
|
Log.d("RemoteConfig", "Fetching workflows for device: $deviceId")
|
||||||
|
val devicePk = resolveDevicePk(deviceId) ?: run {
|
||||||
|
Log.e("RemoteConfig", "Device not found: $deviceId")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val raw = client.get(
|
||||||
|
"${BuildConfig.NOCOBASE_URL}/api/workflow:list"
|
||||||
|
) {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
url {
|
||||||
|
parameters.append("filter", """{"device_key":"$devicePk"}""")
|
||||||
|
parameters.appendAll(
|
||||||
|
"appends[]", listOf(
|
||||||
|
"workflow_steps",
|
||||||
|
"workflow_steps.function",
|
||||||
|
"workflow_steps.content_block"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.bodyAsText()
|
||||||
|
Log.d("RemoteConfig", "Raw response: $raw")
|
||||||
|
val response = Json { ignoreUnknownKeys = true }
|
||||||
|
.decodeFromString<NbListResponse<NbWorkflow>>(raw)
|
||||||
|
val workflows = response.data.map { it.toDomain() }
|
||||||
|
Log.d("RemoteConfig", "Fetched ${workflows.size} workflow(s)")
|
||||||
|
workflows
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to fetch workflows: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logEvent(
|
||||||
|
deviceId: String,
|
||||||
|
workflowName: String,
|
||||||
|
functionId: String,
|
||||||
|
resultCode: String?,
|
||||||
|
durationSeconds: Double
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
client.post("${BuildConfig.NOCOBASE_URL}/api/event_log:create") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(EventLogPayload(deviceId, workflowName, functionId, resultCode, durationSeconds))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to log event: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveDevicePk(deviceId: String): Long? {
|
||||||
|
return try {
|
||||||
|
val raw = client.get(
|
||||||
|
"${BuildConfig.NOCOBASE_URL}/api/devices:list"
|
||||||
|
) {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
url { parameters.append("filter", """{"device_id":"$deviceId"}""") }
|
||||||
|
}.bodyAsText()
|
||||||
|
Json { ignoreUnknownKeys = true }
|
||||||
|
.decodeFromString<NbListResponse<NbDeviceRef>>(raw)
|
||||||
|
.data.firstOrNull()?.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppSettings(
|
||||||
|
val deviceId: String = "",
|
||||||
|
val workflows: List<Workflow> = emptyList(),
|
||||||
|
val gridSize: Int = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private const val SETTINGS_PREFS = "launcher_settings"
|
||||||
|
private const val KEY_APP_SETTINGS = "app_settings"
|
||||||
|
|
||||||
|
object SettingsRepository {
|
||||||
|
fun load(context: Context): AppSettings {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE)
|
||||||
|
val raw = prefs.getString(KEY_APP_SETTINGS, null) ?: return AppSettings()
|
||||||
|
return try { json.decodeFromString(raw) } catch (_: Exception) { AppSettings() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(context: Context, settings: AppSettings) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putString(KEY_APP_SETTINGS, Json.encodeToString(settings)).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WorkflowSpeakerChoiceScreen(
|
||||||
|
onChoice: (String) -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler { onCancel() }
|
||||||
|
val normalLabel = stringResource(R.string.speaker_normal_call)
|
||||||
|
val speakerLabel = stringResource(R.string.speaker_speakerphone)
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
SpeakerOptionHalf(
|
||||||
|
imageRes = R.drawable.speakeroff,
|
||||||
|
label = normalLabel,
|
||||||
|
backgroundColor = Color(0xFFF1F8E9),
|
||||||
|
onClick = { onChoice("normal") },
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFFBDBDBD))
|
||||||
|
)
|
||||||
|
SpeakerOptionHalf(
|
||||||
|
imageRes = R.drawable.speakeron,
|
||||||
|
label = speakerLabel,
|
||||||
|
backgroundColor = Color(0xFFE3F2FD),
|
||||||
|
onClick = { onChoice("speakerphone") },
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpeakerOptionHalf(
|
||||||
|
imageRes: Int,
|
||||||
|
label: String,
|
||||||
|
backgroundColor: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.background(backgroundColor)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = imageRes),
|
||||||
|
contentDescription = label,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
data class WorkflowRunState(
|
||||||
|
val workflow: Workflow,
|
||||||
|
val currentStep: WorkflowStep?,
|
||||||
|
val stepContext: Map<String, String> = emptyMap(),
|
||||||
|
val isFinished: Boolean = false,
|
||||||
|
val lastResultCode: String? = null
|
||||||
|
) {
|
||||||
|
fun advance(resultCode: String?): WorkflowRunState {
|
||||||
|
val step = currentStep ?: return copy(isFinished = true)
|
||||||
|
|
||||||
|
if (step.functionKey == "if") {
|
||||||
|
val matched = step.ifResultCode != null && lastResultCode == step.ifResultCode
|
||||||
|
val targetId = if (matched) step.thenStepId else step.elseStepId
|
||||||
|
val nextStep = targetId?.let { id -> workflow.steps.firstOrNull { it.id == id } }
|
||||||
|
?: workflow.steps.filter { it.sortOrder > step.sortOrder }.minByOrNull { it.sortOrder }
|
||||||
|
return copy(currentStep = nextStep, isFinished = nextStep == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextStep = workflow.steps.filter { it.sortOrder > step.sortOrder }.minByOrNull { it.sortOrder }
|
||||||
|
return copy(currentStep = nextStep, isFinished = nextStep == null, lastResultCode = resultCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun param(key: String): String? {
|
||||||
|
val raw = currentStep?.parameterJson?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(raw).jsonObject[key]?.jsonPrimitive?.content
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun start(workflow: Workflow): WorkflowRunState {
|
||||||
|
val startStep = workflow.steps.minByOrNull { it.sortOrder }
|
||||||
|
return WorkflowRunState(
|
||||||
|
workflow = workflow,
|
||||||
|
currentStep = startStep,
|
||||||
|
isFinished = startStep == null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.example.ichwillheim
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContentBlock(
|
||||||
|
val id: Long,
|
||||||
|
val type: String, // "heading", "text", "image", "button"
|
||||||
|
val value: String,
|
||||||
|
val position: Int,
|
||||||
|
val resultCode: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WorkflowStep(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val functionKey: String,
|
||||||
|
val sortOrder: Int,
|
||||||
|
val parameterJson: String? = null,
|
||||||
|
val ifResultCode: String? = null,
|
||||||
|
val thenStepId: Long? = null,
|
||||||
|
val elseStepId: Long? = null,
|
||||||
|
val contentBlocks: List<ContentBlock> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Workflow(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val icon: String? = null,
|
||||||
|
val homepagePosition: Int? = null,
|
||||||
|
val buttonSpan: Int = 1, // 1 = one cell, 2 = full row (both columns)
|
||||||
|
val steps: List<WorkflowStep> = emptyList(),
|
||||||
|
)
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Common actions -->
|
||||||
|
<string name="action_cancel">Abbrechen</string>
|
||||||
|
<string name="action_done">Fertig</string>
|
||||||
|
<string name="action_save">Speichern</string>
|
||||||
|
<string name="action_confirm">Bestätigen</string>
|
||||||
|
<string name="action_next">Weiter</string>
|
||||||
|
<string name="action_close">Schließen</string>
|
||||||
|
<string name="action_call">Anrufen</string>
|
||||||
|
<string name="action_open_settings">Einstellungen öffnen</string>
|
||||||
|
<string name="action_add_contact">Kontakt hinzufügen</string>
|
||||||
|
<string name="action_add_guide">Guide hinzufügen</string>
|
||||||
|
<string name="action_add_step">Schritt hinzufügen</string>
|
||||||
|
<string name="action_edit_help_screen">Hilfe-Bildschirm bearbeiten</string>
|
||||||
|
|
||||||
|
<!-- Content descriptions -->
|
||||||
|
<string name="cd_previous">Zurück</string>
|
||||||
|
<string name="cd_next">Weiter</string>
|
||||||
|
<string name="cd_back">Zurück</string>
|
||||||
|
<string name="cd_caregiver_settings">Einstellungen</string>
|
||||||
|
<string name="cd_edit_contact">%1$s bearbeiten</string>
|
||||||
|
<string name="cd_delete_contact">%1$s löschen</string>
|
||||||
|
<string name="cd_edit_steps">Schritte bearbeiten</string>
|
||||||
|
<string name="cd_delete_guide">Guide löschen</string>
|
||||||
|
<string name="cd_edit_step">Schritt bearbeiten</string>
|
||||||
|
<string name="cd_delete_step">Schritt löschen</string>
|
||||||
|
|
||||||
|
<!-- Home screen -->
|
||||||
|
<string name="home_new_messages">Sie haben neue Nachrichten</string>
|
||||||
|
<string name="btn_contacts">Kontakte</string>
|
||||||
|
<string name="btn_messages">Nachrichten</string>
|
||||||
|
<string name="btn_camera">Kamera</string>
|
||||||
|
<string name="photo_placeholder">Foto %1$d</string>
|
||||||
|
|
||||||
|
<!-- Favorites screen -->
|
||||||
|
<string name="favorites_who_to_call">Wen möchten Sie anrufen?</string>
|
||||||
|
|
||||||
|
<!-- Caregiver menu -->
|
||||||
|
<string name="caregiver_menu_title">Einstellungsmenü</string>
|
||||||
|
<string name="launcher_settings_title">Einstellungen</string>
|
||||||
|
<string name="caregiver_menu_phone_settings">Telefoneinstellungen</string>
|
||||||
|
|
||||||
|
<!-- PIN screen -->
|
||||||
|
<string name="pin_screen_title">Einstellungszugang</string>
|
||||||
|
<string name="field_pin">PIN</string>
|
||||||
|
<string name="pin_error_incorrect">Falscher PIN</string>
|
||||||
|
|
||||||
|
<!-- Launcher settings -->
|
||||||
|
<string name="edit_contacts_title">Kontakte bearbeiten</string>
|
||||||
|
<string name="settings_language_section">Sprache</string>
|
||||||
|
<string name="settings_language_system">Systemsprache</string>
|
||||||
|
<string name="settings_device_section">Gerät</string>
|
||||||
|
<string name="settings_device_id">Geräte-ID</string>
|
||||||
|
<string name="settings_call_flow_section">Ablauf vor dem Anruf</string>
|
||||||
|
<string name="settings_call_flow_description">Wählen Sie, welche Schritte vor jedem Anruf angezeigt werden.</string>
|
||||||
|
<string name="settings_call_overlay_section">Anruf-Overlay</string>
|
||||||
|
<string name="settings_call_overlay_label">Overlay während des Anrufs</string>
|
||||||
|
<string name="step_reminders_label">Erinnerungen</string>
|
||||||
|
<string name="step_reminders_desc">Wichtige Informationen vor dem Anruf</string>
|
||||||
|
<string name="step_help_offer_label">Hilfe anbieten</string>
|
||||||
|
<string name="step_help_offer_desc">Fragen ob der Patient Hilfe benötigt</string>
|
||||||
|
<string name="step_call_confirm_label">Anruf bestätigen</string>
|
||||||
|
<string name="step_call_confirm_desc">Anruf bestätigen lassen</string>
|
||||||
|
<string name="step_speaker_choice_label">Lautsprecherwahl</string>
|
||||||
|
<string name="step_speaker_choice_desc">Normal- oder Lautsprecheranruf wählen</string>
|
||||||
|
|
||||||
|
<!-- Overlay permission dialog -->
|
||||||
|
<string name="overlay_permission_title">Berechtigung erforderlich</string>
|
||||||
|
<string name="overlay_permission_text">Um die Anruf-Erinnerung anzuzeigen, erteilen Sie bitte die Berechtigung \"Über anderen Apps anzeigen\" in den Einstellungen.</string>
|
||||||
|
|
||||||
|
<!-- Contacts screen -->
|
||||||
|
<string name="dialog_add_contact">Kontakt hinzufügen</string>
|
||||||
|
<string name="dialog_edit_contact">Kontakt bearbeiten</string>
|
||||||
|
<string name="field_name">Name</string>
|
||||||
|
<string name="field_phone">Telefonnummer</string>
|
||||||
|
<string name="field_reminder_message">Erinnerungsnachricht (optional)</string>
|
||||||
|
|
||||||
|
<!-- Pre-call steps -->
|
||||||
|
<string name="reminders_step_title">Bevor Sie %1$s anrufen</string>
|
||||||
|
<string name="call_confirm_title">Möchten Sie %1$s anrufen?</string>
|
||||||
|
<string name="help_offer_no_guides">Keine Hilfe verfügbar.</string>
|
||||||
|
<string name="guide_no_steps">Keine Schritte vorhanden.</string>
|
||||||
|
<string name="guide_step_counter">%1$d / %2$d</string>
|
||||||
|
|
||||||
|
<!-- Help config screen -->
|
||||||
|
<string name="help_config_title">Hilfe-Bildschirm</string>
|
||||||
|
<string name="field_title">Titel</string>
|
||||||
|
<string name="field_subtitle">Untertitel (optional)</string>
|
||||||
|
<string name="help_config_guides_section">Guides</string>
|
||||||
|
<string name="dialog_add_guide">Guide hinzufügen</string>
|
||||||
|
<string name="dialog_rename_guide">Guide umbenennen</string>
|
||||||
|
<string name="dialog_add_step">Schritt hinzufügen</string>
|
||||||
|
<string name="dialog_edit_step">Schritt bearbeiten</string>
|
||||||
|
<string name="field_text">Text</string>
|
||||||
|
|
||||||
|
<!-- Speaker choice and call overlay -->
|
||||||
|
<string name="speaker_normal_call">Normaler Anruf</string>
|
||||||
|
<string name="speaker_speakerphone">Lautsprecher</string>
|
||||||
|
<string name="overlay_hang_up">Auflegen</string>
|
||||||
|
<string name="cd_toggle_speakerphone">Lautsprecher umschalten</string>
|
||||||
|
|
||||||
|
<!-- Errors and permissions -->
|
||||||
|
<string name="error_phone_app_not_found">Telefon-App nicht gefunden</string>
|
||||||
|
<string name="error_messages_app_not_found">Nachrichten-App nicht gefunden</string>
|
||||||
|
<string name="error_camera_app_not_found">Kamera-App nicht gefunden</string>
|
||||||
|
<string name="permission_call_missing">Anrufberechtigung fehlt</string>
|
||||||
|
|
||||||
|
<!-- Plurals -->
|
||||||
|
<plurals name="guide_step_count">
|
||||||
|
<item quantity="one">1 Schritt</item>
|
||||||
|
<item quantity="other">%d Schritte</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Common actions -->
|
||||||
|
<string name="action_cancel">Cancelar</string>
|
||||||
|
<string name="action_done">Listo</string>
|
||||||
|
<string name="action_save">Guardar</string>
|
||||||
|
<string name="action_confirm">Confirmar</string>
|
||||||
|
<string name="action_next">Siguiente</string>
|
||||||
|
<string name="action_close">Cerrar</string>
|
||||||
|
<string name="action_call">Llamar</string>
|
||||||
|
<string name="action_open_settings">Abrir ajustes</string>
|
||||||
|
<string name="action_add_contact">Añadir contacto</string>
|
||||||
|
<string name="action_add_guide">Añadir guía</string>
|
||||||
|
<string name="action_add_step">Añadir paso</string>
|
||||||
|
<string name="action_edit_help_screen">Editar pantalla de ayuda</string>
|
||||||
|
|
||||||
|
<!-- Content descriptions -->
|
||||||
|
<string name="cd_previous">Anterior</string>
|
||||||
|
<string name="cd_next">Siguiente</string>
|
||||||
|
<string name="cd_back">Atrás</string>
|
||||||
|
<string name="cd_caregiver_settings">Ajustes del cuidador</string>
|
||||||
|
<string name="cd_edit_contact">Editar %1$s</string>
|
||||||
|
<string name="cd_delete_contact">Eliminar %1$s</string>
|
||||||
|
<string name="cd_edit_steps">Editar pasos</string>
|
||||||
|
<string name="cd_delete_guide">Eliminar guía</string>
|
||||||
|
<string name="cd_edit_step">Editar paso</string>
|
||||||
|
<string name="cd_delete_step">Eliminar paso</string>
|
||||||
|
|
||||||
|
<!-- Home screen -->
|
||||||
|
<string name="home_new_messages">Tiene mensajes nuevos</string>
|
||||||
|
<string name="btn_contacts">Contactos</string>
|
||||||
|
<string name="btn_messages">Mensajes</string>
|
||||||
|
<string name="btn_camera">Cámara</string>
|
||||||
|
<string name="photo_placeholder">Foto %1$d</string>
|
||||||
|
|
||||||
|
<!-- Favorites screen -->
|
||||||
|
<string name="favorites_who_to_call">¿A quién desea llamar?</string>
|
||||||
|
|
||||||
|
<!-- Caregiver menu -->
|
||||||
|
<string name="caregiver_menu_title">Menú del cuidador</string>
|
||||||
|
<string name="launcher_settings_title">Ajustes del lanzador</string>
|
||||||
|
<string name="caregiver_menu_phone_settings">Ajustes del teléfono</string>
|
||||||
|
|
||||||
|
<!-- PIN screen -->
|
||||||
|
<string name="pin_screen_title">Acceso del cuidador</string>
|
||||||
|
<string name="field_pin">PIN</string>
|
||||||
|
<string name="pin_error_incorrect">PIN incorrecto</string>
|
||||||
|
|
||||||
|
<!-- Launcher settings -->
|
||||||
|
<string name="edit_contacts_title">Editar contactos</string>
|
||||||
|
<string name="settings_language_section">Idioma</string>
|
||||||
|
<string name="settings_language_system">Idioma del sistema</string>
|
||||||
|
<string name="settings_device_section">Dispositivo</string>
|
||||||
|
<string name="settings_device_id">ID de dispositivo</string>
|
||||||
|
<string name="settings_call_flow_section">Flujo de llamada</string>
|
||||||
|
<string name="settings_call_flow_description">Elija qué pasos se muestran antes de cada llamada.</string>
|
||||||
|
<string name="settings_call_overlay_section">Superposición de llamada</string>
|
||||||
|
<string name="settings_call_overlay_label">Superposición durante la llamada</string>
|
||||||
|
<string name="step_reminders_label">Recordatorios</string>
|
||||||
|
<string name="step_reminders_desc">Información importante antes de la llamada</string>
|
||||||
|
<string name="step_help_offer_label">Ofrecer ayuda</string>
|
||||||
|
<string name="step_help_offer_desc">Preguntar si el paciente necesita ayuda</string>
|
||||||
|
<string name="step_call_confirm_label">Confirmar llamada</string>
|
||||||
|
<string name="step_call_confirm_desc">Pedir al paciente que confirme la llamada</string>
|
||||||
|
<string name="step_speaker_choice_label">Elección de altavoz</string>
|
||||||
|
<string name="step_speaker_choice_desc">Elegir entre llamada normal o con altavoz</string>
|
||||||
|
|
||||||
|
<!-- Overlay permission dialog -->
|
||||||
|
<string name="overlay_permission_title">Permiso requerido</string>
|
||||||
|
<string name="overlay_permission_text">Para mostrar la superposición de llamada, conceda el permiso \"Mostrar sobre otras apps\" en los ajustes.</string>
|
||||||
|
|
||||||
|
<!-- Contacts screen -->
|
||||||
|
<string name="dialog_add_contact">Añadir contacto</string>
|
||||||
|
<string name="dialog_edit_contact">Editar contacto</string>
|
||||||
|
<string name="field_name">Nombre</string>
|
||||||
|
<string name="field_phone">Número de teléfono</string>
|
||||||
|
<string name="field_reminder_message">Mensaje recordatorio (opcional)</string>
|
||||||
|
|
||||||
|
<!-- Pre-call steps -->
|
||||||
|
<string name="reminders_step_title">Antes de llamar a %1$s</string>
|
||||||
|
<string name="call_confirm_title">¿Desea llamar a %1$s?</string>
|
||||||
|
<string name="help_offer_no_guides">No hay ayuda disponible.</string>
|
||||||
|
<string name="guide_no_steps">No hay pasos disponibles.</string>
|
||||||
|
<string name="guide_step_counter">%1$d / %2$d</string>
|
||||||
|
|
||||||
|
<!-- Help config screen -->
|
||||||
|
<string name="help_config_title">Pantalla de ayuda</string>
|
||||||
|
<string name="field_title">Título</string>
|
||||||
|
<string name="field_subtitle">Subtítulo (opcional)</string>
|
||||||
|
<string name="help_config_guides_section">Guías</string>
|
||||||
|
<string name="dialog_add_guide">Añadir guía</string>
|
||||||
|
<string name="dialog_rename_guide">Renombrar guía</string>
|
||||||
|
<string name="dialog_add_step">Añadir paso</string>
|
||||||
|
<string name="dialog_edit_step">Editar paso</string>
|
||||||
|
<string name="field_text">Texto</string>
|
||||||
|
|
||||||
|
<!-- Speaker choice and call overlay -->
|
||||||
|
<string name="speaker_normal_call">Llamada normal</string>
|
||||||
|
<string name="speaker_speakerphone">Altavoz</string>
|
||||||
|
<string name="overlay_hang_up">Colgar</string>
|
||||||
|
<string name="cd_toggle_speakerphone">Alternar altavoz</string>
|
||||||
|
|
||||||
|
<!-- Errors and permissions -->
|
||||||
|
<string name="error_phone_app_not_found">Aplicación de teléfono no encontrada</string>
|
||||||
|
<string name="error_messages_app_not_found">Aplicación de mensajes no encontrada</string>
|
||||||
|
<string name="error_camera_app_not_found">Aplicación de cámara no encontrada</string>
|
||||||
|
<string name="permission_call_missing">Permiso de llamada faltante</string>
|
||||||
|
|
||||||
|
<!-- Plurals -->
|
||||||
|
<plurals name="guide_step_count">
|
||||||
|
<item quantity="one">1 paso</item>
|
||||||
|
<item quantity="other">%d pasos</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 45 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 42 KiB |
@@ -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
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user