"Custom" and "Apps" Workflow types added. Fixed paginate error (20 rows max).
This commit is contained in:
@@ -76,11 +76,11 @@ Everything is data-driven. New buttons, screens, and branching logic are configu
|
|||||||
|
|
||||||
| function_id | behaviour |
|
| 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`. |
|
| `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"`. |
|
| `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. |
|
| `open_app` | Launches `package_name` from `parameter_json` via `packageManager.getLaunchIntentForPackage`. Advances with `null`. Shows a toast if the app isn't installed. |
|
||||||
| `show_tutorial` | Same as `show_reminder` — mechanically identical. Both render `ContentScreen`. |
|
| `show_content` | Renders `ContentScreen` with the step's `content_block` list (heading/text/image/button). A button block advances with its own `result_code`; `result_code == "cancel"` always ends the workflow regardless of step type. With no button block, a default "Next" button advances with `null`. |
|
||||||
|
| `show_app_list` | Renders `AppListScreen` — a scrolling grid of every `workflow_type == "app"` workflow for the device (the equivalent of a stock launcher's swipe-up app drawer). Tapping a tile starts that workflow directly, same as a home-grid button. |
|
||||||
| `if` | Checks `lastResultCode == if_result_code`. Jumps to `then_step` if true, `else_step` if false. Falls back to next by sort_order if targets not set. |
|
| `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. |
|
| `label` | No-op. Used as a named jump target for `if` steps. Advances to next step by sort_order. |
|
||||||
|
|
||||||
@@ -90,8 +90,7 @@ Everything is data-driven. New buttons, screens, and branching logic are configu
|
|||||||
|
|
||||||
| result_code | emitted by |
|
| result_code | emitted by |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `confirmed` | `show_call_confirm` (user tapped confirm) |
|
| `cancel` | any `show_content` button tagged as cancel, or `show_speaker_choice` (user tapped cancel) — always ends the workflow |
|
||||||
| `cancelled` | `show_call_confirm` or `show_speaker_choice` (user tapped cancel) |
|
|
||||||
| `speakerphone` | `show_speaker_choice` |
|
| `speakerphone` | `show_speaker_choice` |
|
||||||
| `normal` | `show_speaker_choice` |
|
| `normal` | `show_speaker_choice` |
|
||||||
|
|
||||||
@@ -167,22 +166,26 @@ Everything is data-driven. New buttons, screens, and branching logic are configu
|
|||||||
|
|
||||||
| function_id | name |
|
| function_id | name |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `show_call_confirm` | Show Call Confirm |
|
|
||||||
| `show_speaker_choice` | Show Speaker Choice |
|
| `show_speaker_choice` | Show Speaker Choice |
|
||||||
| `call_contact` | Call Contact |
|
| `call_contact` | Call Contact |
|
||||||
|
| `open_app` | Open App |
|
||||||
|
| `show_content` | Show Content |
|
||||||
|
| `show_app_list` | Show App List |
|
||||||
| `if` | If / Branch |
|
| `if` | If / Branch |
|
||||||
| `label` | Label |
|
| `label` | Label |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `workflow` — one per home screen button per device
|
### `workflow` — one per home screen button per device, or one per auto-synced installed app
|
||||||
|
|
||||||
| field | type | notes |
|
| field | type | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `name` | string | e.g. "Call Sandro" |
|
| `name` | string | e.g. "Call Sandro", or the app's label for `workflow_type == "app"` |
|
||||||
| `homepage_position` | integer / null | 1-indexed position in the 2-column grid; null = not on home screen |
|
| `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 |
|
| `button_span` | integer | 1 = normal (1 cell), 2 = wide (full row); default 1 |
|
||||||
| `is_active` | boolean | |
|
| `workflow_type` | select | `custom` (default, caregiver-authored) or `app` (auto-synced from an installed app) |
|
||||||
|
| `package_name` | string / null | set only for `workflow_type == "app"`; matched against on re-sync so a caregiver's added tutorial step or homepage_position always survives |
|
||||||
|
| `is_active` | boolean | for `app` workflows: set to `false` on uninstall instead of deleting, so a reinstall reactivates the same row rather than duplicating it |
|
||||||
| `device` | belongsTo → devices | |
|
| `device` | belongsTo → devices | |
|
||||||
| `workflow_steps` | hasMany → workflow_step | fetched via `workflow_steps` append |
|
| `workflow_steps` | hasMany → workflow_step | fetched via `workflow_steps` append |
|
||||||
|
|
||||||
@@ -253,8 +256,7 @@ The caregiver web app dashboard groups these rows by device user → workflow
|
|||||||
|
|
||||||
| sort_order | function_id | parameter_json |
|
| sort_order | function_id | parameter_json |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | `show_call_confirm` | — |
|
| 1 | `call_contact` | `{"phone": "+41768224400"}` |
|
||||||
| 2 | `call_contact` | `{"phone": "+41768224400"}` |
|
|
||||||
|
|
||||||
Steps run in order. No transitions, no if steps needed.
|
Steps run in order. No transitions, no if steps needed.
|
||||||
|
|
||||||
@@ -264,15 +266,14 @@ Steps run in order. No transitions, no if steps needed.
|
|||||||
|
|
||||||
| sort_order | function_id | parameter_json | if_result_code | then_step | else_step |
|
| sort_order | function_id | parameter_json | if_result_code | then_step | else_step |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 1 | `show_call_confirm` | — | — | — | — |
|
| 1 | `show_speaker_choice` | — | — | — | — |
|
||||||
| 2 | `show_speaker_choice` | — | — | — | — |
|
| 2 | `if` | — | `speakerphone` | → step 3 | → step 4 |
|
||||||
| 3 | `if` | — | `speakerphone` | → step 4 | → step 5 |
|
| 3 | `label` | — | — | — | — |
|
||||||
| 4 | `label` | — | — | — | — |
|
| 4 | `call_contact` | `{"phone":"…","speakerphone":true}` | — | — | — |
|
||||||
| 5 | `call_contact` | `{"phone":"…","speakerphone":true}` | — | — | — |
|
| 5 | `label` | — | — | — | — |
|
||||||
| 6 | `label` | — | — | — | — |
|
| 6 | `call_contact` | `{"phone":"…","speakerphone":false}` | — | — | — |
|
||||||
| 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).
|
Step 2 reads `lastResultCode` from step 1. If it equals `"speakerphone"`, jumps to step 3 (label) → step 4 (call with speaker). Otherwise jumps to step 5 (label) → step 6 (normal call).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -281,18 +282,25 @@ Step 3 reads `lastResultCode` from step 2. If it equals `"speakerphone"`, jumps
|
|||||||
```
|
```
|
||||||
app/src/main/java/com/example/ichwillheim/
|
app/src/main/java/com/example/ichwillheim/
|
||||||
│
|
│
|
||||||
├── MainActivity.kt — entry point, navigation state, workflow runner, call utilities
|
├── MainActivity.kt — entry point, step dispatcher (the `when` block), call utilities
|
||||||
├── HomeScreen.kt — patient home screen (clock, workflow buttons)
|
├── ui/
|
||||||
├── ContentScreen.kt — data-driven content renderer for show_reminder / show_tutorial steps
|
│ ├── HomeScreen.kt — patient home screen (clock, workflow buttons)
|
||||||
├── PreCallStepsScreen.kt — WorkflowConfirmScreen (show_call_confirm)
|
│ ├── ContentScreen.kt — data-driven content renderer for show_content steps
|
||||||
├── SpeakerChoiceScreen.kt — WorkflowSpeakerChoiceScreen, SpeakerOptionHalf
|
│ ├── SpeakerChoiceScreen.kt — WorkflowSpeakerChoiceScreen, SpeakerOptionHalf
|
||||||
├── PinScreen.kt — PIN entry screen
|
│ ├── PinScreen.kt — PIN entry screen
|
||||||
├── LauncherSettingsScreen.kt — SettingsMenuScreen, LauncherSettingsScreen, LanguagePicker
|
│ ├── LauncherSettingsScreen.kt — SettingsMenuScreen, LauncherSettingsScreen, LanguagePicker
|
||||||
├── CallOverlayService.kt — foreground service for call overlay
|
│ └── theme/ — Color.kt, Theme.kt, Type.kt
|
||||||
├── WorkflowModels.kt — Workflow, WorkflowStep, ContentBlock
|
├── viewmodel/
|
||||||
├── WorkflowEngine.kt — WorkflowRunState: advance(), param(), start()
|
│ └── LauncherViewModel.kt — screen state + navigation flags, survives config changes
|
||||||
├── SettingsRepository.kt — AppSettings + SharedPreferences
|
├── domain/
|
||||||
└── RemoteConfigRepository.kt — Ktor HTTP client; fetches workflows from NocoBase
|
│ ├── WorkflowModels.kt — Workflow, WorkflowStep, ContentBlock
|
||||||
|
│ └── WorkflowEngine.kt — WorkflowRunState: advance(), param(), start()
|
||||||
|
├── data/
|
||||||
|
│ ├── SettingsRepository.kt — AppSettings + SharedPreferences
|
||||||
|
│ ├── RemoteConfigRepository.kt — Ktor HTTP client; fetches workflows from NocoBase
|
||||||
|
│ └── InstalledApps.kt — LauncherApps enumeration of launchable apps
|
||||||
|
└── service/
|
||||||
|
└── CallOverlayService.kt — foreground service for call overlay
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.telephony" android:required="false" />
|
<uses-feature android:name="android.hardware.telephony" android:required="false" />
|
||||||
@@ -39,9 +46,19 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".CallOverlayService"
|
android:name=".service.CallOverlayService"
|
||||||
android:foregroundServiceType="specialUse"
|
android:foregroundServiceType="specialUse"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".service.PackageChangeReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PACKAGE_ADDED" />
|
||||||
|
<action android:name="android.intent.action.PACKAGE_REMOVED" />
|
||||||
|
<data android:scheme="package" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package com.example.ichwillheim
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private data class GridCell(
|
|
||||||
val workflow: Workflow?,
|
|
||||||
val isConsumed: Boolean = false // true when occupied by the second cell of a span-2 button
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HomeScreen(
|
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
workflows: List<Workflow> = emptyList(),
|
|
||||||
gridSize: Int = 6,
|
|
||||||
onStartWorkflow: (Workflow) -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val cols = 2
|
|
||||||
|
|
||||||
// The caregiver may place buttons beyond the device's gridSize setting.
|
|
||||||
// Instead of hiding them, auto-extend to the row that contains the highest button.
|
|
||||||
val highestCell = workflows
|
|
||||||
.filter { it.homepagePosition != null }
|
|
||||||
.maxOfOrNull { w -> w.homepagePosition!! + (w.buttonSpan - 1) } ?: 0
|
|
||||||
val rowsFromContent = if (highestCell > 0) (highestCell + 1) / 2 else 0
|
|
||||||
val rows = maxOf(gridSize / cols, rowsFromContent)
|
|
||||||
val effectiveGridSize = rows * cols
|
|
||||||
|
|
||||||
// Build a flat array of cells.
|
|
||||||
// span=2 means the button spans the full row (both columns).
|
|
||||||
// We only allow span=2 when the button starts at column 0 (odd position in 1-indexed).
|
|
||||||
val cells = Array<GridCell>(effectiveGridSize) { GridCell(null) }
|
|
||||||
workflows
|
|
||||||
.filter { it.homepagePosition != null && it.homepagePosition in 1..effectiveGridSize }
|
|
||||||
.forEach { wf ->
|
|
||||||
val pos = wf.homepagePosition!! - 1
|
|
||||||
if (cells[pos].isConsumed) return@forEach
|
|
||||||
val span = if (wf.buttonSpan == 2 && pos % cols == 0 && pos + 1 < effectiveGridSize) 2 else 1
|
|
||||||
cells[pos] = GridCell(wf.copy(buttonSpan = span))
|
|
||||||
if (span == 2) cells[pos + 1] = GridCell(null, isConsumed = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
// Clock: 1 share of vertical space
|
|
||||||
ClockSection(modifier = Modifier.weight(1f).fillMaxWidth())
|
|
||||||
|
|
||||||
// Grid: 3 shares — about 75% of the screen
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(3f).fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
for (row in 0 until rows) {
|
|
||||||
val rowStart = row * cols
|
|
||||||
val firstCell = cells[rowStart]
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
if (firstCell.workflow?.buttonSpan == 2) {
|
|
||||||
// Span-2: button fills the entire row
|
|
||||||
WorkflowButton(
|
|
||||||
label = firstCell.workflow.name,
|
|
||||||
onClick = { onStartWorkflow(firstCell.workflow) },
|
|
||||||
modifier = Modifier.fillMaxWidth().fillMaxHeight()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Normal: two side-by-side cells
|
|
||||||
for (col in 0 until cols) {
|
|
||||||
val cell = cells[rowStart + col]
|
|
||||||
when {
|
|
||||||
cell.isConsumed -> { /* skip — covered by a span-2 button */ }
|
|
||||||
cell.workflow != null ->
|
|
||||||
WorkflowButton(
|
|
||||||
label = cell.workflow.name,
|
|
||||||
onClick = { onStartWorkflow(cell.workflow) },
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight()
|
|
||||||
)
|
|
||||||
else ->
|
|
||||||
EmptyGridCell(modifier = Modifier.weight(1f).fillMaxHeight())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onOpenSettings,
|
|
||||||
modifier = Modifier.padding(top = 4.dp).size(44.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = stringResource(R.string.cd_caregiver_settings)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun WorkflowButton(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onClick,
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EmptyGridCell(modifier: Modifier = Modifier) {
|
|
||||||
Box(modifier = modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ClockSection(modifier: Modifier = Modifier) {
|
|
||||||
var time by remember { mutableStateOf("") }
|
|
||||||
var day by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
val calendar = Calendar.getInstance()
|
|
||||||
val hour = calendar.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
|
||||||
val minute = calendar.get(Calendar.MINUTE).toString().padStart(2, '0')
|
|
||||||
time = "$hour:$minute"
|
|
||||||
day = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: ""
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(text = time, fontSize = 72.sp, fontWeight = FontWeight.Light)
|
|
||||||
Text(
|
|
||||||
text = day,
|
|
||||||
fontSize = 28.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,13 +29,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -43,7 +36,18 @@ import androidx.core.net.toUri
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.example.ichwillheim.service.CallOverlayService
|
||||||
|
import com.example.ichwillheim.service.EXTRA_SPEAKERPHONE
|
||||||
|
import com.example.ichwillheim.ui.AppListScreen
|
||||||
|
import com.example.ichwillheim.ui.ContentScreen
|
||||||
|
import com.example.ichwillheim.ui.HomeScreen
|
||||||
|
import com.example.ichwillheim.ui.LauncherSettingsScreen
|
||||||
|
import com.example.ichwillheim.ui.PinScreen
|
||||||
|
import com.example.ichwillheim.ui.SettingsMenuScreen
|
||||||
|
import com.example.ichwillheim.ui.WorkflowSpeakerChoiceScreen
|
||||||
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
|
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
|
||||||
|
import com.example.ichwillheim.viewmodel.LauncherViewModel
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -60,11 +64,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LauncherScreen(modifier: Modifier = Modifier) {
|
fun LauncherScreen(modifier: Modifier = Modifier, viewModel: LauncherViewModel = viewModel()) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var settings by remember { mutableStateOf(SettingsRepository.load(context)) }
|
|
||||||
|
|
||||||
// Request CALL_PHONE permission at startup if not already granted.
|
// Request CALL_PHONE permission at startup if not already granted.
|
||||||
// CALL_PHONE is a "dangerous" permission — having it in the manifest is not enough on
|
// CALL_PHONE is a "dangerous" permission — having it in the manifest is not enough on
|
||||||
@@ -80,197 +81,107 @@ fun LauncherScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation flags — only one is true at a time.
|
|
||||||
var showPin by remember { mutableStateOf(false) }
|
|
||||||
var showCaregiverMenu by remember { mutableStateOf(false) }
|
|
||||||
var showLauncherSettings by remember { mutableStateOf(false) }
|
|
||||||
var activeRun by remember { mutableStateOf<WorkflowRunState?>(null) }
|
|
||||||
var stepStartTime by remember { mutableStateOf(System.currentTimeMillis()) }
|
|
||||||
LaunchedEffect(activeRun?.currentStep?.id) { stepStartTime = System.currentTimeMillis() }
|
|
||||||
|
|
||||||
// Fetch remote config on every resume so the home screen stays up-to-date when
|
// Fetch remote config on every resume so the home screen stays up-to-date when
|
||||||
// the caregiver adds or changes workflows without restarting the app.
|
// the caregiver adds or changes workflows without restarting the app.
|
||||||
// Also resets sub-screen navigation so the patient always returns to the home screen.
|
// Also resets sub-screen navigation so the patient always returns to the home screen.
|
||||||
val settingsRef = rememberUpdatedState(settings)
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_RESUME) {
|
if (event == Lifecycle.Event.ON_RESUME) viewModel.onResume()
|
||||||
showPin = false
|
|
||||||
showCaregiverMenu = false
|
|
||||||
showLauncherSettings = false
|
|
||||||
activeRun = null
|
|
||||||
if (settingsRef.value.deviceId.isNotBlank()) {
|
|
||||||
scope.launch {
|
|
||||||
val remote = RemoteConfigRepository.fetchWorkflows(settingsRef.value.deviceId)
|
|
||||||
if (remote != null) {
|
|
||||||
val updated = settingsRef.value.copy(workflows = remote)
|
|
||||||
SettingsRepository.save(context, updated)
|
|
||||||
settings = updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val anySubScreenOpen = showPin || showCaregiverMenu || showLauncherSettings || activeRun != null
|
val activeRun = viewModel.activeRun
|
||||||
|
val anySubScreenOpen = viewModel.showPin || viewModel.showCaregiverMenu ||
|
||||||
|
viewModel.showLauncherSettings || activeRun != null
|
||||||
BackHandler(enabled = !anySubScreenOpen) { /* swallow back on main screen */ }
|
BackHandler(enabled = !anySubScreenOpen) { /* swallow back on main screen */ }
|
||||||
|
|
||||||
if (activeRun != null) {
|
if (activeRun != null) {
|
||||||
val run = activeRun!!
|
android.util.Log.d("WorkflowEngine", "Step: ${activeRun.currentStep?.functionKey}, finished: ${activeRun.isFinished}")
|
||||||
android.util.Log.d("WorkflowEngine", "Step: ${run.currentStep?.functionKey}, finished: ${run.isFinished}")
|
when (activeRun.currentStep?.functionKey) {
|
||||||
when (run.currentStep?.functionKey) {
|
|
||||||
"show_call_confirm" -> WorkflowConfirmScreen(
|
|
||||||
title = run.workflow.name,
|
|
||||||
onConfirm = {
|
|
||||||
val completedStep = run.currentStep
|
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
|
||||||
val next = run.advance("confirmed")
|
|
||||||
activeRun = next.takeIf { !next.isFinished }
|
|
||||||
if (completedStep != null && settings.deviceId.isNotBlank()) {
|
|
||||||
scope.launch {
|
|
||||||
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "confirmed", duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel = {
|
|
||||||
val completedStep = run.currentStep
|
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
|
||||||
activeRun = null
|
|
||||||
if (completedStep != null && settings.deviceId.isNotBlank()) {
|
|
||||||
scope.launch {
|
|
||||||
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "cancelled", duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
"show_speaker_choice" -> WorkflowSpeakerChoiceScreen(
|
"show_speaker_choice" -> WorkflowSpeakerChoiceScreen(
|
||||||
onChoice = { choice ->
|
onChoice = { choice -> viewModel.advance(choice) },
|
||||||
val completedStep = run.currentStep
|
onCancel = { viewModel.cancelActiveRun() }
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
|
||||||
val next = run.advance(choice)
|
|
||||||
activeRun = next.takeIf { !next.isFinished }
|
|
||||||
if (completedStep != null && settings.deviceId.isNotBlank()) {
|
|
||||||
scope.launch {
|
|
||||||
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, choice, duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel = {
|
|
||||||
val completedStep = run.currentStep
|
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
|
||||||
activeRun = null
|
|
||||||
if (completedStep != null && settings.deviceId.isNotBlank()) {
|
|
||||||
scope.launch {
|
|
||||||
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "cancelled", duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
"call_contact" -> LaunchedEffect(run.currentStep!!.id) {
|
"call_contact" -> LaunchedEffect(activeRun.currentStep!!.id) {
|
||||||
val completedStep = run.currentStep!!
|
val phone = activeRun.param("phone") ?: ""
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
val speakerphone = activeRun.param("speakerphone")?.toBooleanStrictOrNull()
|
||||||
val phone = run.param("phone") ?: ""
|
?: (activeRun.lastResultCode == "speakerphone")
|
||||||
val speakerphone = run.param("speakerphone")?.toBooleanStrictOrNull()
|
|
||||||
?: (run.lastResultCode == "speakerphone")
|
|
||||||
android.util.Log.d("WorkflowEngine", "call_contact: phone='$phone' speakerphone=$speakerphone")
|
android.util.Log.d("WorkflowEngine", "call_contact: phone='$phone' speakerphone=$speakerphone")
|
||||||
if (phone.isBlank()) {
|
if (phone.isBlank()) {
|
||||||
Toast.makeText(context, "No phone number set for this step. Add a contact in the web app.", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "No phone number set for this step. Add a contact in the web app.", Toast.LENGTH_LONG).show()
|
||||||
} else {
|
} else {
|
||||||
performCall(context, phone, speakerphone, showOverlay = false)
|
performCall(context, phone, speakerphone, showOverlay = false)
|
||||||
}
|
}
|
||||||
val next = run.advance(null)
|
viewModel.advance(null)
|
||||||
activeRun = next.takeIf { !next.isFinished }
|
}
|
||||||
if (settings.deviceId.isNotBlank()) {
|
"open_app" -> LaunchedEffect(activeRun.currentStep!!.id) {
|
||||||
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, null, duration)
|
val packageName = activeRun.param("package_name") ?: ""
|
||||||
|
val launchIntent = packageName.takeIf { it.isNotBlank() }
|
||||||
|
?.let { context.packageManager.getLaunchIntentForPackage(it) }
|
||||||
|
if (launchIntent != null) {
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "App not found on this device.", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
viewModel.advance(null)
|
||||||
}
|
}
|
||||||
"show_content" -> ContentScreen(
|
"show_content" -> ContentScreen(
|
||||||
blocks = run.currentStep?.contentBlocks ?: emptyList(),
|
blocks = activeRun.currentStep?.contentBlocks ?: emptyList(),
|
||||||
onAdvance = { resultCode ->
|
onAdvance = { resultCode -> viewModel.advance(resultCode) },
|
||||||
val completedStep = run.currentStep
|
onCancel = { viewModel.cancelActiveRun() }
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
|
||||||
val next = run.advance(resultCode)
|
|
||||||
activeRun = next.takeIf { !next.isFinished }
|
|
||||||
if (completedStep != null && settings.deviceId.isNotBlank()) {
|
|
||||||
scope.launch {
|
|
||||||
RemoteConfigRepository.logEvent(
|
|
||||||
settings.deviceId, run.workflow.name,
|
|
||||||
completedStep.functionKey, resultCode, duration
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel = {
|
|
||||||
val completedStep = run.currentStep
|
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
|
||||||
activeRun = null
|
|
||||||
if (completedStep != null && settings.deviceId.isNotBlank()) {
|
|
||||||
scope.launch {
|
|
||||||
RemoteConfigRepository.logEvent(
|
|
||||||
settings.deviceId, run.workflow.name,
|
|
||||||
completedStep.functionKey, "cancelled", duration
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
else -> LaunchedEffect(run.currentStep?.id) {
|
"show_app_list" -> AppListScreen(
|
||||||
val completedStep = run.currentStep
|
workflows = viewModel.settings.workflows,
|
||||||
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
|
onStartWorkflow = { workflow -> viewModel.startWorkflow(workflow) },
|
||||||
val next = run.advance(null)
|
onCancel = { viewModel.cancelActiveRun() }
|
||||||
activeRun = next.takeIf { !next.isFinished }
|
)
|
||||||
if (completedStep != null && settings.deviceId.isNotBlank()) {
|
else -> LaunchedEffect(activeRun.currentStep?.id) {
|
||||||
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, null, duration)
|
viewModel.advance(null)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showLauncherSettings) {
|
if (viewModel.showLauncherSettings) {
|
||||||
LauncherSettingsScreen(
|
LauncherSettingsScreen(
|
||||||
settings = settings,
|
settings = viewModel.settings,
|
||||||
onSettingsChanged = { updated ->
|
onSettingsChanged = { updated -> viewModel.updateSettings(updated) },
|
||||||
settings = updated
|
onClose = { viewModel.closeLauncherSettings() },
|
||||||
SettingsRepository.save(context, updated)
|
isSyncingApps = viewModel.isSyncingApps,
|
||||||
},
|
appSyncResultCount = viewModel.appSyncResultCount,
|
||||||
onClose = { showLauncherSettings = false }
|
onSyncApps = { viewModel.syncInstalledApps() }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCaregiverMenu) {
|
if (viewModel.showCaregiverMenu) {
|
||||||
SettingsMenuScreen(
|
SettingsMenuScreen(
|
||||||
onLauncherSettings = { showLauncherSettings = true },
|
onLauncherSettings = { viewModel.openLauncherSettings() },
|
||||||
onPhoneSettings = {
|
onPhoneSettings = {
|
||||||
showCaregiverMenu = false
|
viewModel.closeCaregiverMenu()
|
||||||
context.startActivity(Intent(Settings.ACTION_SETTINGS))
|
context.startActivity(Intent(Settings.ACTION_SETTINGS))
|
||||||
},
|
},
|
||||||
onClose = { showCaregiverMenu = false }
|
onClose = { viewModel.closeCaregiverMenu() }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showPin) {
|
if (viewModel.showPin) {
|
||||||
PinScreen(
|
PinScreen(
|
||||||
onCorrectPin = {
|
onCorrectPin = { viewModel.confirmPin() },
|
||||||
showPin = false
|
onCancel = { viewModel.cancelPin() }
|
||||||
showCaregiverMenu = true
|
|
||||||
},
|
|
||||||
onCancel = { showPin = false }
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
onOpenSettings = { showPin = true },
|
onOpenSettings = { viewModel.openPin() },
|
||||||
workflows = settings.workflows,
|
workflows = viewModel.settings.workflows,
|
||||||
gridSize = settings.gridSize,
|
gridSize = viewModel.settings.gridSize,
|
||||||
onStartWorkflow = { workflow -> activeRun = WorkflowRunState.start(workflow).takeIf { !it.isFinished } },
|
onStartWorkflow = { workflow -> viewModel.startWorkflow(workflow) },
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.example.ichwillheim
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun WorkflowConfirmScreen(
|
|
||||||
title: String,
|
|
||||||
onConfirm: () -> Unit,
|
|
||||||
onCancel: () -> Unit
|
|
||||||
) {
|
|
||||||
BackHandler { onCancel() }
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = onCancel,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
|
||||||
modifier = Modifier.weight(1f).height(72.dp)
|
|
||||||
) { Text(stringResource(R.string.action_cancel), fontSize = 18.sp) }
|
|
||||||
Button(
|
|
||||||
onClick = onConfirm,
|
|
||||||
modifier = Modifier.weight(1f).height(72.dp)
|
|
||||||
) { Text(stringResource(R.string.action_call), fontSize = 18.sp) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
package com.example.ichwillheim
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.statement.bodyAsText
|
|
||||||
import io.ktor.client.engine.android.Android
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.request.header
|
|
||||||
import io.ktor.client.request.post
|
|
||||||
import io.ktor.client.request.setBody
|
|
||||||
import io.ktor.http.ContentType
|
|
||||||
import io.ktor.http.contentType
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
// --- DTOs ---
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class EventLogPayload(
|
|
||||||
@SerialName("device_id") val deviceId: String,
|
|
||||||
@SerialName("workflow_name") val workflowName: String,
|
|
||||||
@SerialName("function_id") val functionId: String,
|
|
||||||
@SerialName("result_code") val resultCode: String? = null,
|
|
||||||
@SerialName("duration_seconds") val durationSeconds: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- NocoBase response DTOs ---
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class NbListResponse<T>(val data: List<T>)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class NbWorkflow(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val icon: String? = null,
|
|
||||||
@SerialName("homepage_position") val homepagePosition: Int? = null,
|
|
||||||
@SerialName("button_span") val buttonSpan: Int? = null,
|
|
||||||
@SerialName("workflow_steps") val steps: List<NbStep> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class NbStep(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val function: NbFunction? = null,
|
|
||||||
@SerialName("sort_order") val sortOrder: Int? = null,
|
|
||||||
@SerialName("parameter_json") val parameterJson: String? = null,
|
|
||||||
@SerialName("if_result_code") val ifResultCode: String? = null,
|
|
||||||
@SerialName("then_step_key") val thenStepKey: Long? = null,
|
|
||||||
@SerialName("else_step_key") val elseStepKey: Long? = null,
|
|
||||||
@SerialName("content_block") val contentBlocks: List<NbContentBlock> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class NbFunction(
|
|
||||||
val id: Long,
|
|
||||||
@SerialName("function_id") val functionKey: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class NbContentBlock(
|
|
||||||
val id: Long,
|
|
||||||
val type: String,
|
|
||||||
val value: String,
|
|
||||||
@SerialName("sort_order") val sortOrder: Int? = null,
|
|
||||||
@SerialName("result_code") val resultCode: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class NbRef(val id: Long)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class NbDeviceRef(val id: Long)
|
|
||||||
|
|
||||||
// --- Mapping from DTOs to domain models ---
|
|
||||||
|
|
||||||
private fun NbWorkflow.toDomain() = Workflow(
|
|
||||||
id = id,
|
|
||||||
name = name,
|
|
||||||
icon = icon,
|
|
||||||
homepagePosition = homepagePosition,
|
|
||||||
buttonSpan = buttonSpan ?: 1,
|
|
||||||
steps = steps.map { it.toDomain() },
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun NbStep.toDomain() = WorkflowStep(
|
|
||||||
id = id,
|
|
||||||
name = name,
|
|
||||||
functionKey = function?.functionKey ?: "",
|
|
||||||
sortOrder = sortOrder ?: Int.MAX_VALUE,
|
|
||||||
parameterJson = parameterJson,
|
|
||||||
ifResultCode = ifResultCode,
|
|
||||||
thenStepId = thenStepKey,
|
|
||||||
elseStepId = elseStepKey,
|
|
||||||
contentBlocks = contentBlocks.sortedBy { it.sortOrder ?: 0 }.map { it.toDomain() }
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun NbContentBlock.toDomain() = ContentBlock(
|
|
||||||
id = id,
|
|
||||||
type = type,
|
|
||||||
value = value,
|
|
||||||
position = sortOrder ?: 0,
|
|
||||||
resultCode = resultCode,
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Repository ---
|
|
||||||
|
|
||||||
object RemoteConfigRepository {
|
|
||||||
|
|
||||||
private val client = HttpClient(Android) {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json { ignoreUnknownKeys = true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchWorkflows(deviceId: String): List<Workflow>? {
|
|
||||||
return try {
|
|
||||||
Log.d("RemoteConfig", "Fetching workflows for device: $deviceId")
|
|
||||||
val devicePk = resolveDevicePk(deviceId) ?: run {
|
|
||||||
Log.e("RemoteConfig", "Device not found: $deviceId")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val raw = client.get(
|
|
||||||
"${BuildConfig.NOCOBASE_URL}/api/workflow:list"
|
|
||||||
) {
|
|
||||||
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
|
||||||
url {
|
|
||||||
parameters.append("filter", """{"device_key":"$devicePk"}""")
|
|
||||||
parameters.appendAll(
|
|
||||||
"appends[]", listOf(
|
|
||||||
"workflow_steps",
|
|
||||||
"workflow_steps.function",
|
|
||||||
"workflow_steps.content_block"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.bodyAsText()
|
|
||||||
Log.d("RemoteConfig", "Raw response: $raw")
|
|
||||||
val response = Json { ignoreUnknownKeys = true }
|
|
||||||
.decodeFromString<NbListResponse<NbWorkflow>>(raw)
|
|
||||||
val workflows = response.data.map { it.toDomain() }
|
|
||||||
Log.d("RemoteConfig", "Fetched ${workflows.size} workflow(s)")
|
|
||||||
workflows
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("RemoteConfig", "Failed to fetch workflows: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun logEvent(
|
|
||||||
deviceId: String,
|
|
||||||
workflowName: String,
|
|
||||||
functionId: String,
|
|
||||||
resultCode: String?,
|
|
||||||
durationSeconds: Double
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
client.post("${BuildConfig.NOCOBASE_URL}/api/event_log:create") {
|
|
||||||
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(EventLogPayload(deviceId, workflowName, functionId, resultCode, durationSeconds))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("RemoteConfig", "Failed to log event: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun resolveDevicePk(deviceId: String): Long? {
|
|
||||||
return try {
|
|
||||||
val raw = client.get(
|
|
||||||
"${BuildConfig.NOCOBASE_URL}/api/devices:list"
|
|
||||||
) {
|
|
||||||
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
|
||||||
url { parameters.append("filter", """{"device_id":"$deviceId"}""") }
|
|
||||||
}.bodyAsText()
|
|
||||||
Json { ignoreUnknownKeys = true }
|
|
||||||
.decodeFromString<NbListResponse<NbDeviceRef>>(raw)
|
|
||||||
.data.firstOrNull()?.id
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
package com.example.ichwillheim.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.ichwillheim.BuildConfig
|
||||||
|
import com.example.ichwillheim.domain.ContentBlock
|
||||||
|
import com.example.ichwillheim.domain.Workflow
|
||||||
|
import com.example.ichwillheim.domain.WorkflowStep
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.client.engine.android.Android
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
// --- DTOs ---
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class EventLogPayload(
|
||||||
|
@SerialName("device_id") val deviceId: String,
|
||||||
|
@SerialName("workflow_name") val workflowName: String,
|
||||||
|
@SerialName("function_id") val functionId: String,
|
||||||
|
@SerialName("result_code") val resultCode: String? = null,
|
||||||
|
@SerialName("duration_seconds") val durationSeconds: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- NocoBase response DTOs ---
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbListResponse<T>(val data: List<T>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbWorkflow(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val icon: String? = null,
|
||||||
|
@SerialName("homepage_position") val homepagePosition: Int? = null,
|
||||||
|
@SerialName("button_span") val buttonSpan: Int? = null,
|
||||||
|
@SerialName("workflow_type") val workflowType: String? = null,
|
||||||
|
@SerialName("package_name") val packageName: String? = null,
|
||||||
|
@SerialName("workflow_steps") val steps: List<NbStep> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbStep(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val function: NbFunction? = null,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||||
|
@SerialName("parameter_json") val parameterJson: String? = null,
|
||||||
|
@SerialName("if_result_code") val ifResultCode: String? = null,
|
||||||
|
@SerialName("then_step_key") val thenStepKey: Long? = null,
|
||||||
|
@SerialName("else_step_key") val elseStepKey: Long? = null,
|
||||||
|
@SerialName("content_block") val contentBlocks: List<NbContentBlock> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbFunction(
|
||||||
|
val id: Long,
|
||||||
|
@SerialName("function_id") val functionKey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbContentBlock(
|
||||||
|
val id: Long,
|
||||||
|
val type: String,
|
||||||
|
val value: String,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||||
|
@SerialName("result_code") val resultCode: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbRef(val id: Long)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbDeviceRef(val id: Long)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbSingleResponse<T>(val data: T)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NbWorkflowNameRef(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
@SerialName("is_active") val isActive: Boolean? = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- App workflow sync DTOs ---
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class WorkflowCreatePayload(
|
||||||
|
val name: String,
|
||||||
|
@SerialName("workflow_type") val workflowType: String,
|
||||||
|
@SerialName("package_name") val packageName: String,
|
||||||
|
@SerialName("device_key") val deviceKey: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class WorkflowUpdatePayload(val name: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class WorkflowActiveUpdatePayload(@SerialName("is_active") val isActive: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class StepCreatePayload(
|
||||||
|
val name: String,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int,
|
||||||
|
@SerialName("parameter_json") val parameterJson: String? = null,
|
||||||
|
@SerialName("function_key") val functionKey: Long,
|
||||||
|
@SerialName("workflow_key") val workflowKey: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ContentBlockCreatePayload(
|
||||||
|
val type: String,
|
||||||
|
val value: String,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int,
|
||||||
|
@SerialName("result_code") val resultCode: String? = null,
|
||||||
|
@SerialName("content_block_key") val contentBlockKey: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Mapping from DTOs to domain models ---
|
||||||
|
|
||||||
|
private fun NbWorkflow.toDomain() = Workflow(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
icon = icon,
|
||||||
|
homepagePosition = homepagePosition,
|
||||||
|
buttonSpan = buttonSpan ?: 1,
|
||||||
|
workflowType = workflowType ?: "custom",
|
||||||
|
packageName = packageName,
|
||||||
|
steps = steps.map { it.toDomain() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NbStep.toDomain() = WorkflowStep(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
functionKey = function?.functionKey ?: "",
|
||||||
|
sortOrder = sortOrder ?: Int.MAX_VALUE,
|
||||||
|
parameterJson = parameterJson,
|
||||||
|
ifResultCode = ifResultCode,
|
||||||
|
thenStepId = thenStepKey,
|
||||||
|
elseStepId = elseStepKey,
|
||||||
|
contentBlocks = contentBlocks.sortedBy { it.sortOrder ?: 0 }.map { it.toDomain() }
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NbContentBlock.toDomain() = ContentBlock(
|
||||||
|
id = id,
|
||||||
|
type = type,
|
||||||
|
value = value,
|
||||||
|
position = sortOrder ?: 0,
|
||||||
|
resultCode = resultCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Repository ---
|
||||||
|
|
||||||
|
object RemoteConfigRepository {
|
||||||
|
|
||||||
|
private val client = HttpClient(Android) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchWorkflows(deviceId: String): List<Workflow>? {
|
||||||
|
return try {
|
||||||
|
Log.d("RemoteConfig", "Fetching workflows for device: $deviceId")
|
||||||
|
val devicePk = resolveDevicePk(deviceId) ?: run {
|
||||||
|
Log.e("RemoteConfig", "Device not found: $deviceId")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val raw = client.get(
|
||||||
|
"${BuildConfig.NOCOBASE_URL}/api/workflow:list"
|
||||||
|
) {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
url {
|
||||||
|
parameters.append("filter", """{"device_key":"$devicePk"}""")
|
||||||
|
parameters.append("paginate","false")
|
||||||
|
parameters.appendAll(
|
||||||
|
"appends[]", listOf(
|
||||||
|
"workflow_steps",
|
||||||
|
"workflow_steps.function",
|
||||||
|
"workflow_steps.content_block"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.bodyAsText()
|
||||||
|
Log.d("RemoteConfig", "Raw response: $raw")
|
||||||
|
val response = Json { ignoreUnknownKeys = true }
|
||||||
|
.decodeFromString<NbListResponse<NbWorkflow>>(raw)
|
||||||
|
val workflows = response.data.map { it.toDomain() }
|
||||||
|
Log.d("RemoteConfig", "Fetched ${workflows.size} workflow(s)")
|
||||||
|
workflows
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to fetch workflows: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logEvent(
|
||||||
|
deviceId: String,
|
||||||
|
workflowName: String,
|
||||||
|
functionId: String,
|
||||||
|
resultCode: String?,
|
||||||
|
durationSeconds: Double
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
client.post("${BuildConfig.NOCOBASE_URL}/api/event_log:create") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(EventLogPayload(deviceId, workflowName, functionId, resultCode, durationSeconds))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to log event: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a `workflow_type=app` workflow for one installed app if none exists yet for
|
||||||
|
// this device+package. Never touches an existing row's steps — a caregiver's added
|
||||||
|
// tutorial step or homepage_position survives every re-sync. Only the name is
|
||||||
|
// refreshed if the app's label changed (e.g. after an app update).
|
||||||
|
suspend fun upsertAppWorkflow(deviceId: String, packageName: String, label: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val devicePk = resolveDevicePk(deviceId) ?: return false
|
||||||
|
val existing = findExistingAppWorkflow(devicePk, packageName)
|
||||||
|
if (existing != null) {
|
||||||
|
if (existing.name != label) updateWorkflowName(existing.id, label)
|
||||||
|
if (existing.isActive == false) updateWorkflowActive(existing.id, isActive = true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val workflowId = createWorkflow(
|
||||||
|
WorkflowCreatePayload(
|
||||||
|
name = label, workflowType = "app", packageName = packageName, deviceKey = devicePk
|
||||||
|
)
|
||||||
|
) ?: return false
|
||||||
|
|
||||||
|
var sortOrder = 1
|
||||||
|
AppTemplates.presets[packageName]?.let { blocks ->
|
||||||
|
val contentFunctionPk = resolveFunctionPk("show_content")
|
||||||
|
val stepId = contentFunctionPk?.let {
|
||||||
|
createStep(
|
||||||
|
StepCreatePayload(
|
||||||
|
name = "Intro", sortOrder = sortOrder, functionKey = it, workflowKey = workflowId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (stepId != null) {
|
||||||
|
sortOrder++
|
||||||
|
blocks.forEachIndexed { index, block ->
|
||||||
|
createContentBlock(
|
||||||
|
ContentBlockCreatePayload(
|
||||||
|
type = block.type,
|
||||||
|
value = block.value,
|
||||||
|
sortOrder = index,
|
||||||
|
resultCode = block.resultCode,
|
||||||
|
contentBlockKey = stepId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val openAppFunctionPk = resolveFunctionPk("open_app") ?: return false
|
||||||
|
createStep(
|
||||||
|
StepCreatePayload(
|
||||||
|
name = label,
|
||||||
|
sortOrder = sortOrder,
|
||||||
|
parameterJson = """{"package_name":"$packageName"}""",
|
||||||
|
functionKey = openAppFunctionPk,
|
||||||
|
workflowKey = workflowId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to upsert app workflow for $packageName: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marks an app's workflow inactive on uninstall rather than deleting it, so a
|
||||||
|
// caregiver's tutorial edits survive a later reinstall (upsertAppWorkflow
|
||||||
|
// reactivates it instead of creating a duplicate).
|
||||||
|
suspend fun deactivateAppWorkflow(deviceId: String, packageName: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val devicePk = resolveDevicePk(deviceId) ?: return false
|
||||||
|
val existing = findExistingAppWorkflow(devicePk, packageName) ?: return true
|
||||||
|
updateWorkflowActive(existing.id, isActive = false)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to deactivate app workflow for $packageName: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun findExistingAppWorkflow(devicePk: Long, packageName: String): NbWorkflowNameRef? {
|
||||||
|
return try {
|
||||||
|
val raw = client.get("${BuildConfig.NOCOBASE_URL}/api/workflow:list") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
url { parameters.append("filter", """{"device_key":"$devicePk","package_name":"$packageName"}""") }
|
||||||
|
}.bodyAsText()
|
||||||
|
Json { ignoreUnknownKeys = true }
|
||||||
|
.decodeFromString<NbListResponse<NbWorkflowNameRef>>(raw).data.firstOrNull()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to check existing app workflow for $packageName: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createWorkflow(payload: WorkflowCreatePayload): Long? {
|
||||||
|
return try {
|
||||||
|
val raw = client.post("${BuildConfig.NOCOBASE_URL}/api/workflow:create") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(payload)
|
||||||
|
}.bodyAsText()
|
||||||
|
Json { ignoreUnknownKeys = true }.decodeFromString<NbSingleResponse<NbRef>>(raw).data.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to create workflow for ${payload.packageName}: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createStep(payload: StepCreatePayload): Long? {
|
||||||
|
return try {
|
||||||
|
val raw = client.post("${BuildConfig.NOCOBASE_URL}/api/workflow_step:create") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(payload)
|
||||||
|
}.bodyAsText()
|
||||||
|
Json { ignoreUnknownKeys = true }.decodeFromString<NbSingleResponse<NbRef>>(raw).data.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to create workflow_step: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createContentBlock(payload: ContentBlockCreatePayload) {
|
||||||
|
try {
|
||||||
|
client.post("${BuildConfig.NOCOBASE_URL}/api/content_block:create") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(payload)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to create content_block: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateWorkflowName(workflowId: Long, name: String) {
|
||||||
|
try {
|
||||||
|
client.post("${BuildConfig.NOCOBASE_URL}/api/workflow:update") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
url { parameters.append("filterByTk", workflowId.toString()) }
|
||||||
|
setBody(WorkflowUpdatePayload(name))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to update workflow name: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateWorkflowActive(workflowId: Long, isActive: Boolean) {
|
||||||
|
try {
|
||||||
|
client.post("${BuildConfig.NOCOBASE_URL}/api/workflow:update") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
url { parameters.append("filterByTk", workflowId.toString()) }
|
||||||
|
setBody(WorkflowActiveUpdatePayload(isActive))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to update workflow active state: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveFunctionPk(functionId: String): Long? {
|
||||||
|
return try {
|
||||||
|
val raw = client.get("${BuildConfig.NOCOBASE_URL}/api/workflow_function:list") {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
url { parameters.append("filter", """{"function_id":"$functionId"}""") }
|
||||||
|
}.bodyAsText()
|
||||||
|
Json { ignoreUnknownKeys = true }.decodeFromString<NbListResponse<NbRef>>(raw).data.firstOrNull()?.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to resolve function pk for $functionId: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveDevicePk(deviceId: String): Long? {
|
||||||
|
return try {
|
||||||
|
val raw = client.get(
|
||||||
|
"${BuildConfig.NOCOBASE_URL}/api/devices:list"
|
||||||
|
) {
|
||||||
|
header("Authorization", "Bearer ${BuildConfig.NOCOBASE_TOKEN}")
|
||||||
|
url { parameters.append("filter", """{"device_id":"$deviceId"}""") }
|
||||||
|
}.bodyAsText()
|
||||||
|
Json { ignoreUnknownKeys = true }
|
||||||
|
.decodeFromString<NbListResponse<NbDeviceRef>>(raw)
|
||||||
|
.data.firstOrNull()?.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("RemoteConfig", "Failed to resolve device PK: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.example.ichwillheim.domain.Workflow
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.domain
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.domain
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -31,5 +31,7 @@ data class Workflow(
|
|||||||
val icon: String? = null,
|
val icon: String? = null,
|
||||||
val homepagePosition: Int? = null,
|
val homepagePosition: Int? = null,
|
||||||
val buttonSpan: Int = 1, // 1 = one cell, 2 = full row (both columns)
|
val buttonSpan: Int = 1, // 1 = one cell, 2 = full row (both columns)
|
||||||
|
val workflowType: String = "custom", // "custom" (caregiver-authored) or "app" (auto-synced from an installed app)
|
||||||
|
val packageName: String? = null, // set only for workflowType == "app"
|
||||||
val steps: List<WorkflowStep> = emptyList(),
|
val steps: List<WorkflowStep> = emptyList(),
|
||||||
)
|
)
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.service
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -56,6 +56,7 @@ import androidx.savedstate.SavedStateRegistry
|
|||||||
import androidx.savedstate.SavedStateRegistryController
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
import androidx.savedstate.SavedStateRegistryOwner
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import com.example.ichwillheim.R
|
||||||
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
|
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
|
||||||
|
|
||||||
const val EXTRA_SPEAKERPHONE = "speakerphone"
|
const val EXTRA_SPEAKERPHONE = "speakerphone"
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -25,6 +25,8 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.ichwillheim.R
|
||||||
|
import com.example.ichwillheim.domain.ContentBlock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic content screen for data-driven workflow steps.
|
* Generic content screen for data-driven workflow steps.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.example.ichwillheim.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.ichwillheim.R
|
||||||
|
import com.example.ichwillheim.domain.Workflow
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
workflows: List<Workflow> = emptyList(),
|
||||||
|
gridSize: Int = 6,
|
||||||
|
onStartWorkflow: (Workflow) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Clock: 1 share of vertical space
|
||||||
|
ClockSection(modifier = Modifier.weight(1f).fillMaxWidth())
|
||||||
|
|
||||||
|
// Grid: 3 shares — about 75% of the screen
|
||||||
|
WorkflowGrid(
|
||||||
|
workflows = workflows,
|
||||||
|
gridSize = gridSize,
|
||||||
|
onStartWorkflow = onStartWorkflow,
|
||||||
|
modifier = Modifier.weight(3f).fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
modifier = Modifier.padding(top = 4.dp).size(44.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = stringResource(R.string.cd_caregiver_settings)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClockSection(modifier: Modifier = Modifier) {
|
||||||
|
var time by remember { mutableStateOf("") }
|
||||||
|
var day by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
val hour = calendar.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
|
val minute = calendar.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
|
time = "$hour:$minute"
|
||||||
|
day = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: ""
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(text = time, fontSize = 72.sp, fontWeight = FontWeight.Light)
|
||||||
|
Text(
|
||||||
|
text = day,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
@@ -25,6 +25,8 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import com.example.ichwillheim.R
|
||||||
|
import com.example.ichwillheim.data.AppSettings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsMenuScreen(
|
fun SettingsMenuScreen(
|
||||||
@@ -69,7 +71,10 @@ fun SettingsMenuScreen(
|
|||||||
fun LauncherSettingsScreen(
|
fun LauncherSettingsScreen(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
onSettingsChanged: (AppSettings) -> Unit,
|
onSettingsChanged: (AppSettings) -> Unit,
|
||||||
onClose: () -> Unit
|
onClose: () -> Unit,
|
||||||
|
isSyncingApps: Boolean = false,
|
||||||
|
appSyncResultCount: Int? = null,
|
||||||
|
onSyncApps: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
BackHandler { onClose() }
|
BackHandler { onClose() }
|
||||||
|
|
||||||
@@ -101,6 +106,22 @@ fun LauncherSettingsScreen(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_apps_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Button(onClick = onSyncApps, enabled = !isSyncingApps) {
|
||||||
|
Text(
|
||||||
|
if (isSyncingApps) stringResource(R.string.settings_apps_syncing)
|
||||||
|
else stringResource(R.string.action_sync_apps)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appSyncResultCount?.let { count ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(stringResource(R.string.settings_apps_synced, count), fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(stringResource(R.string.settings_language_section), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.settings_language_section), style = MaterialTheme.typography.titleMedium)
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.ichwillheim.R
|
||||||
|
|
||||||
const val SETTINGS_PIN = "1234"
|
const val SETTINGS_PIN = "1234"
|
||||||
|
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.ichwillheim
|
package com.example.ichwillheim.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.ichwillheim.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WorkflowSpeakerChoiceScreen(
|
fun WorkflowSpeakerChoiceScreen(
|
||||||
@@ -52,6 +52,11 @@
|
|||||||
<string name="settings_language_system">Systemsprache</string>
|
<string name="settings_language_system">Systemsprache</string>
|
||||||
<string name="settings_device_section">Gerät</string>
|
<string name="settings_device_section">Gerät</string>
|
||||||
<string name="settings_device_id">Geräte-ID</string>
|
<string name="settings_device_id">Geräte-ID</string>
|
||||||
|
<string name="settings_apps_section">Apps</string>
|
||||||
|
<string name="action_sync_apps">Installierte Apps synchronisieren</string>
|
||||||
|
<string name="settings_apps_syncing">Wird synchronisiert…</string>
|
||||||
|
<string name="settings_apps_synced">%1$d Apps synchronisiert</string>
|
||||||
|
<string name="app_list_title">Apps</string>
|
||||||
<string name="settings_call_flow_section">Ablauf vor dem Anruf</string>
|
<string name="settings_call_flow_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_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_section">Anruf-Overlay</string>
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
<string name="settings_language_system">Idioma del sistema</string>
|
<string name="settings_language_system">Idioma del sistema</string>
|
||||||
<string name="settings_device_section">Dispositivo</string>
|
<string name="settings_device_section">Dispositivo</string>
|
||||||
<string name="settings_device_id">ID de dispositivo</string>
|
<string name="settings_device_id">ID de dispositivo</string>
|
||||||
|
<string name="settings_apps_section">Aplicaciones</string>
|
||||||
|
<string name="action_sync_apps">Sincronizar aplicaciones instaladas</string>
|
||||||
|
<string name="settings_apps_syncing">Sincronizando…</string>
|
||||||
|
<string name="settings_apps_synced">%1$d aplicaciones sincronizadas</string>
|
||||||
|
<string name="app_list_title">Aplicaciones</string>
|
||||||
<string name="settings_call_flow_section">Flujo de llamada</string>
|
<string name="settings_call_flow_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_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_section">Superposición de llamada</string>
|
||||||
|
|||||||
@@ -54,6 +54,11 @@
|
|||||||
<string name="settings_language_system">System default</string>
|
<string name="settings_language_system">System default</string>
|
||||||
<string name="settings_device_section">Device</string>
|
<string name="settings_device_section">Device</string>
|
||||||
<string name="settings_device_id">Device ID</string>
|
<string name="settings_device_id">Device ID</string>
|
||||||
|
<string name="settings_apps_section">Apps</string>
|
||||||
|
<string name="action_sync_apps">Sync installed apps</string>
|
||||||
|
<string name="settings_apps_syncing">Syncing…</string>
|
||||||
|
<string name="settings_apps_synced">%1$d apps synced</string>
|
||||||
|
<string name="app_list_title">Apps</string>
|
||||||
<string name="settings_call_flow_section">Call flow</string>
|
<string name="settings_call_flow_section">Call flow</string>
|
||||||
<string name="settings_call_flow_description">Choose which steps are shown before each call.</string>
|
<string name="settings_call_flow_description">Choose which steps are shown before each call.</string>
|
||||||
<string name="settings_call_overlay_section">Call overlay</string>
|
<string name="settings_call_overlay_section">Call overlay</string>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ espressoCore = "3.7.0"
|
|||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.12.2"
|
activityCompose = "1.12.2"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2024.09.00"
|
||||||
|
workRuntimeKtx = "2.10.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -17,6 +18,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j
|
|||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
@@ -27,6 +29,7 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
|
|||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user