Fixed show_content bug. Buttons cancel and continue doesn't work.

This commit is contained in:
2026-06-05 23:30:34 +02:00
parent 19ba25715a
commit 9133103e82
11 changed files with 489 additions and 134 deletions
@@ -1,36 +1,51 @@
package com.example.ichwillheim
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.Settings
import android.telephony.TelephonyManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.core.content.ContextCompat
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
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.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.example.ichwillheim.ui.theme.IchWillHeimTheme
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -47,59 +62,217 @@ class MainActivity : ComponentActivity() {
@Composable
fun LauncherScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val favorites = listOf(
FavoriteContact(
name = "Sandro",
phone = "+1-555-0100"
),
FavoriteContact(
name = "Paulo",
phone = "+1-555-0111"
)
)
val scope = rememberCoroutineScope()
val apps = listOf(
AppEntry(
label = "Messages",
action = { openMessages(context) }
)
)
var settings by remember { mutableStateOf(SettingsRepository.load(context)) }
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Favorites", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
favorites.forEach { favorite ->
Button(
onClick = { openDialerNumber(context, favorite.phone) },
modifier = Modifier
.fillMaxWidth()
.height(96.dp)
) {
Text(text = favorite.name, fontSize = 24.sp)
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Apps", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
apps.forEach { app ->
Button(
onClick = { app.action() },
modifier = Modifier
.fillMaxWidth()
.height(96.dp)
) {
Text(text = app.label, fontSize = 24.sp)
}
Spacer(modifier = Modifier.height(16.dp))
// Request CALL_PHONE permission at startup if not already granted.
// CALL_PHONE is a "dangerous" permission — having it in the manifest is not enough on
// Android 6+; the user must explicitly grant it the first time.
val callPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* result is ignored — if denied, performCall will show an error at call time */ }
LaunchedEffect(Unit) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE)
!= PackageManager.PERMISSION_GRANTED
) {
callPermissionLauncher.launch(Manifest.permission.CALL_PHONE)
}
}
// Navigation flags — only one is true at a time.
var showPin by remember { mutableStateOf(false) }
var showCaregiverMenu by remember { mutableStateOf(false) }
var showLauncherSettings by remember { mutableStateOf(false) }
var activeRun by remember { mutableStateOf<WorkflowRunState?>(null) }
var stepStartTime by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(activeRun?.currentStep?.id) { stepStartTime = System.currentTimeMillis() }
// Fetch remote config on every resume so the home screen stays up-to-date when
// the caregiver adds or changes workflows without restarting the app.
// Also resets sub-screen navigation so the patient always returns to the home screen.
val settingsRef = rememberUpdatedState(settings)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
showPin = false
showCaregiverMenu = false
showLauncherSettings = false
activeRun = null
if (settingsRef.value.deviceId.isNotBlank()) {
scope.launch {
val remote = RemoteConfigRepository.fetchWorkflows(settingsRef.value.deviceId)
if (remote != null) {
val updated = settingsRef.value.copy(workflows = remote)
SettingsRepository.save(context, updated)
settings = updated
}
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val anySubScreenOpen = showPin || showCaregiverMenu || showLauncherSettings || activeRun != null
BackHandler(enabled = !anySubScreenOpen) { /* swallow back on main screen */ }
if (activeRun != null) {
val run = activeRun!!
android.util.Log.d("WorkflowEngine", "Step: ${run.currentStep?.functionKey}, finished: ${run.isFinished}")
when (run.currentStep?.functionKey) {
"show_call_confirm" -> WorkflowConfirmScreen(
title = run.workflow.name,
onConfirm = {
val completedStep = run.currentStep
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
val next = run.advance("confirmed")
activeRun = next.takeIf { !next.isFinished }
if (completedStep != null && settings.deviceId.isNotBlank()) {
scope.launch {
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "confirmed", duration)
}
}
},
onCancel = {
val completedStep = run.currentStep
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
activeRun = null
if (completedStep != null && settings.deviceId.isNotBlank()) {
scope.launch {
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "cancelled", duration)
}
}
}
)
"show_speaker_choice" -> WorkflowSpeakerChoiceScreen(
onChoice = { choice ->
val completedStep = run.currentStep
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
val next = run.advance(choice)
activeRun = next.takeIf { !next.isFinished }
if (completedStep != null && settings.deviceId.isNotBlank()) {
scope.launch {
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, choice, duration)
}
}
},
onCancel = {
val completedStep = run.currentStep
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
activeRun = null
if (completedStep != null && settings.deviceId.isNotBlank()) {
scope.launch {
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, "cancelled", duration)
}
}
}
)
"call_contact" -> LaunchedEffect(run.currentStep!!.id) {
val completedStep = run.currentStep!!
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
val phone = run.param("phone") ?: ""
val speakerphone = run.param("speakerphone")?.toBooleanStrictOrNull()
?: (run.lastResultCode == "speakerphone")
android.util.Log.d("WorkflowEngine", "call_contact: phone='$phone' speakerphone=$speakerphone")
if (phone.isBlank()) {
Toast.makeText(context, "No phone number set for this step. Add a contact in the web app.", Toast.LENGTH_LONG).show()
} else {
performCall(context, phone, speakerphone, showOverlay = false)
}
val next = run.advance(null)
activeRun = next.takeIf { !next.isFinished }
if (settings.deviceId.isNotBlank()) {
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, null, duration)
}
}
"show_content" -> ContentScreen(
blocks = run.currentStep?.contentBlocks ?: emptyList(),
onAdvance = { resultCode ->
val completedStep = run.currentStep
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
val next = run.advance(resultCode)
activeRun = next.takeIf { !next.isFinished }
if (completedStep != null && settings.deviceId.isNotBlank()) {
scope.launch {
RemoteConfigRepository.logEvent(
settings.deviceId, run.workflow.name,
completedStep.functionKey, resultCode, duration
)
}
}
},
onCancel = {
val completedStep = run.currentStep
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
activeRun = null
if (completedStep != null && settings.deviceId.isNotBlank()) {
scope.launch {
RemoteConfigRepository.logEvent(
settings.deviceId, run.workflow.name,
completedStep.functionKey, "cancelled", duration
)
}
}
}
)
else -> LaunchedEffect(run.currentStep?.id) {
val completedStep = run.currentStep
val duration = (System.currentTimeMillis() - stepStartTime) / 1000.0
val next = run.advance(null)
activeRun = next.takeIf { !next.isFinished }
if (completedStep != null && settings.deviceId.isNotBlank()) {
RemoteConfigRepository.logEvent(settings.deviceId, run.workflow.name, completedStep.functionKey, null, duration)
}
}
}
return
}
if (showLauncherSettings) {
LauncherSettingsScreen(
settings = settings,
onSettingsChanged = { updated ->
settings = updated
SettingsRepository.save(context, updated)
},
onClose = { showLauncherSettings = false }
)
return
}
if (showCaregiverMenu) {
SettingsMenuScreen(
onLauncherSettings = { showLauncherSettings = true },
onPhoneSettings = {
showCaregiverMenu = false
context.startActivity(Intent(Settings.ACTION_SETTINGS))
},
onClose = { showCaregiverMenu = false }
)
return
}
if (showPin) {
PinScreen(
onCorrectPin = {
showPin = false
showCaregiverMenu = true
},
onCancel = { showPin = false }
)
return
}
HomeScreen(
onOpenSettings = { showPin = true },
workflows = settings.workflows,
gridSize = settings.gridSize,
onStartWorkflow = { workflow -> activeRun = WorkflowRunState.start(workflow).takeIf { !it.isFinished } },
modifier = modifier
)
}
@Preview(showBackground = true)
@@ -110,30 +283,23 @@ fun LauncherPreview() {
}
}
@Immutable
data class AppEntry(
val label: String,
val action: () -> Unit
)
@Immutable
data class FavoriteContact(
val name: String,
val phone: String
)
private fun openDialerNumber(context: Context, phone: String) {
fun openDialerNumber(context: Context, phone: String) {
val intent = Intent(Intent.ACTION_DIAL).apply {
data = "tel:${Uri.encode(phone)}".toUri()
}
startIntentOrToast(context, intent, "Phone app not found")
startIntentOrToast(context, intent, context.getString(R.string.error_phone_app_not_found))
}
private fun openMessages(context: Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "smsto:".toUri()
}
startIntentOrToast(context, intent, "Messages app not found")
startIntentOrToast(context, intent, context.getString(R.string.error_messages_app_not_found))
}
private fun openCamera(context: Context) {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
startIntentOrToast(context, intent, context.getString(R.string.error_camera_app_not_found))
}
private fun startIntentOrToast(context: Context, intent: Intent, error: String) {
@@ -143,3 +309,58 @@ private fun startIntentOrToast(context: Context, intent: Intent, error: String)
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
fun performCall(context: Context, phone: String, speakerphone: Boolean, showOverlay: Boolean) {
val willShowOverlay = showOverlay &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
Settings.canDrawOverlays(context)
// Only use the standalone BroadcastReceiver when the overlay isn't handling speakerphone
if (speakerphone && !willShowOverlay) enableSpeakerphoneOnAnswer(context)
if (willShowOverlay) {
val serviceIntent = Intent(context, CallOverlayService::class.java).apply {
putExtra(EXTRA_SPEAKERPHONE, speakerphone)
}
context.startForegroundService(serviceIntent)
}
val callIntent = Intent(Intent.ACTION_CALL).apply {
data = Uri.parse("tel:$phone")
}
try {
context.startActivity(callIntent)
} catch (_: Exception) {
Toast.makeText(context, context.getString(R.string.error_phone_app_not_found), Toast.LENGTH_SHORT).show()
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private fun enableSpeakerphoneOnAnswer(context: Context) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.getStringExtra(TelephonyManager.EXTRA_STATE)
== TelephonyManager.EXTRA_STATE_OFFHOOK) {
setSpeakerphoneOn(ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
ctx.unregisterReceiver(this)
}
}
}
val filter = IntentFilter(TelephonyManager.ACTION_PHONE_STATE_CHANGED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(receiver, filter)
}
}
private fun setSpeakerphoneOn(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
}
}
@@ -2,10 +2,9 @@ package com.example.ichwillheim.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val PeachLight = Color(0xFFFFF6E5)
val SageGreen = Color(0xFFA6CDB2)
val WarmOrange = Color(0xFFFCAB7E)
val DarkWarm = Color(0xFF2D2D2D)
val PeachMedium = Color(0xFFE8D4BE)
val PeachSurface = Color(0xFFFEF3E8)
@@ -1,58 +1,34 @@
package com.example.ichwillheim.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
private val ColorScheme = lightColorScheme(
primary = SageGreen,
onPrimary = DarkWarm,
primaryContainer = PeachLight,
onPrimaryContainer = DarkWarm,
secondary = WarmOrange,
onSecondary = DarkWarm,
secondaryContainer = PeachLight,
onSecondaryContainer = DarkWarm,
tertiary = PeachLight,
onTertiary = DarkWarm,
background = PeachLight,
onBackground = DarkWarm,
surface = PeachLight,
onSurface = DarkWarm,
surfaceVariant = PeachSurface,
onSurfaceVariant = DarkWarm,
outline = PeachMedium,
)
@Composable
fun IchWillHeimTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
fun IchWillHeimTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = colorScheme,
colorScheme = ColorScheme,
typography = Typography,
content = content
)
}
}