Code revision
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user