Code revision

This commit is contained in:
2026-07-07 09:38:39 +02:00
parent 9133103e82
commit f6f999a153
20 changed files with 2407 additions and 0 deletions
@@ -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(),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+114
View File
@@ -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>
+114
View File
@@ -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>