build(telefon): default phone app with InCallService, call log, favourites, keypad, details

uhr: handle the public AlarmClock intents (set/show alarm and timer, dismiss, snooze); 0.2.1
docs: how each app replaces the system app

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 16:30:12 +02:00
co-authored by Claude Opus 5.5
parent 0f709aaff8
commit 830e653690
45 changed files with 2346 additions and 40 deletions
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET: calls go through the system, nothing leaves the device from this app -->
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- required for the default phone app role -->
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tel" />
</intent-filter>
</activity>
<activity
android:name=".call.CallActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:taskAffinity="" />
<service
android:name=".call.CallService"
android:exported="true"
android:permission="android.permission.BIND_INCALL_SERVICE">
<meta-data android:name="android.telecom.IN_CALL_SERVICE_UI" android:value="true" />
<intent-filter><action android:name="android.telecom.InCallService" /></intent-filter>
</service>
<receiver android:name=".call.CallActionReceiver" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,183 @@
package app.telefon
import android.Manifest
import android.app.role.RoleManager
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import android.telecom.TelecomManager
import android.telephony.TelephonyManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.Dialpad
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Voicemail
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import app.telefon.call.CallService
import app.telefon.data.Contacts
import app.telefon.data.Person
import app.telefon.ui.screens.DetailsScreen
import app.telefon.ui.screens.HomeScreen
import app.telefon.ui.screens.KeypadScreen
import app.telefon.ui.screens.SearchScreen
import app.telefon.ui.screens.VoicemailScreen
import app.telefon.ui.theme.PhoneTheme
import app.telefon.ui.theme.WType
/** Also handles DIAL/VIEW tel: links from other apps (required to be the default phone app). */
class MainActivity : ComponentActivity() {
private val dialNumber = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
CallService.channels(this)
handle(intent)
setContent { PhoneTheme { PhoneApp(dialNumber.value) { dialNumber.value = null } } }
}
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); handle(intent) }
private fun handle(i: Intent?) {
if (i?.action == Intent.ACTION_DIAL || i?.action == Intent.ACTION_VIEW) {
dialNumber.value = i.data?.takeIf { it.scheme == "tel" }?.schemeSpecificPart ?: ""
}
}
}
private sealed interface Screen {
data object Search : Screen
data class Details(val person: Person?, val number: String?) : Screen
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PhoneApp(dialRequest: String?, onDialHandled: () -> Unit) {
val ctx = LocalContext.current
var tab by rememberSaveable { mutableIntStateOf(0) }
var screen by remember { mutableStateOf<Screen?>(null) }
var dialInitial by remember { mutableStateOf("") }
LaunchedEffect(dialRequest) { if (dialRequest != null) { dialInitial = dialRequest; tab = 1; screen = null; onDialHandled() } }
var granted by remember { mutableStateOf(hasCore(ctx)) }
val perms = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted = hasCore(ctx) }
val role = ctx.getSystemService(RoleManager::class.java)
var isDefault by remember { mutableStateOf(role.isRoleHeld(RoleManager.ROLE_DIALER)) }
val roleLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
isDefault = role.isRoleHeld(RoleManager.ROLE_DIALER); granted = hasCore(ctx)
}
LaunchedEffect(Unit) {
if (!granted) perms.launch(arrayOf(Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE,
Manifest.permission.READ_PHONE_STATE, Manifest.permission.POST_NOTIFICATIONS))
}
val log by remember(granted) { Contacts.callLog(ctx) }.collectAsState(initial = emptyList())
val people by remember(granted) { Contacts.people(ctx) }.collectAsState(initial = emptyList())
val call: (String) -> Unit = { n -> placeCall(ctx, n) }
screen?.let { s ->
BackHandler { screen = null }
Scaffold(topBar = {
TopAppBar(title = { if (s is Screen.Search) Text(stringResource(R.string.search)) }, navigationIcon = {
IconButton(onClick = { screen = null }) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) }
})
}) { pad ->
when (s) {
Screen.Search -> SearchScreen(people, pad, call) { screen = Screen.Details(it, null) }
is Screen.Details -> {
val p = s.person?.let { sp -> people.firstOrNull { it.id == sp.id } ?: sp }
DetailsScreen(p, s.number, log, pad, call,
onSms = { n -> ctx.startActivity(Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$n"))) },
onStar = { Contacts.setStarred(ctx, it.id, !it.starred) },
onAddContact = { n -> ctx.startActivity(Intent(Intent.ACTION_INSERT_OR_EDIT).setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE).putExtra(ContactsContract.Intents.Insert.PHONE, n)) },
onOpenContacts = { pp -> ctx.startActivity(Intent(Intent.ACTION_VIEW, ContactsContract.Contacts.getLookupUri(pp.id, pp.lookupKey))) })
}
}
}
return
}
Scaffold(
bottomBar = {
NavigationBar {
listOf(Triple(R.string.tab_home, Icons.Outlined.Home, 0), Triple(R.string.tab_keypad, Icons.Outlined.Dialpad, 1), Triple(R.string.tab_voicemail, Icons.Outlined.Voicemail, 2)).forEach { (t, ic, i) ->
NavigationBarItem(tab == i, { tab = i }, { Icon(ic, null) }, label = { Text(stringResource(t)) })
}
}
},
) { pad ->
Column {
if (!isDefault) {
Column(
Modifier.padding(start = 16.dp, end = 16.dp, top = pad.calculateTopPadding() + 8.dp).clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable { roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_DIALER)) }.padding(16.dp),
) {
Text(stringResource(R.string.default_title), style = WType.body, color = MaterialTheme.colorScheme.onSecondaryContainer)
Text(stringResource(R.string.default_text), style = WType.bodySm, color = MaterialTheme.colorScheme.onSecondaryContainer)
}
}
val inner = if (isDefault) pad else androidx.compose.foundation.layout.PaddingValues(bottom = pad.calculateBottomPadding())
when (tab) {
0 -> HomeScreen(log, people, inner, call, onOpenNumber = { n ->
screen = Screen.Details(people.firstOrNull { p -> p.phones.any { android.telephony.PhoneNumberUtils.compare(it.number, n) } }, n)
}, onOpenPerson = { screen = Screen.Details(it, null) }, onSearch = { screen = Screen.Search })
1 -> KeypadScreen(people, inner, dialInitial, call)
2 -> VoicemailScreen(voicemailNumber(ctx), inner, call)
}
}
}
}
private fun hasCore(ctx: android.content.Context) = listOf(Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
.all { ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED }
/** Through Telecom, so SIM choice and emergency numbers are handled by the system. */
private fun placeCall(ctx: android.content.Context, number: String) {
val uri = Uri.fromParts("tel", number, null)
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
runCatching { ctx.getSystemService(TelecomManager::class.java).placeCall(uri, Bundle()) }
.onFailure { ctx.startActivity(Intent(Intent.ACTION_DIAL, uri)) }
} else ctx.startActivity(Intent(Intent.ACTION_DIAL, uri))
}
@android.annotation.SuppressLint("MissingPermission")
private fun voicemailNumber(ctx: android.content.Context): String? = runCatching {
ctx.getSystemService(TelephonyManager::class.java).voiceMailNumber?.takeIf { it.isNotBlank() }
}.getOrNull()
@@ -0,0 +1,248 @@
package app.telefon.call
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.telecom.CallAudioState
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.CallEnd
import androidx.compose.material.icons.rounded.Dialpad
import androidx.compose.material.icons.rounded.MicOff
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.Phone
import androidx.compose.material.icons.outlined.Sms
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import app.telefon.R
import app.telefon.data.Contacts
import app.telefon.ui.components.Avatar
import app.telefon.ui.components.Fmt
import app.telefon.ui.screens.DialPad
import app.telefon.ui.theme.LocalCallColors
import app.telefon.ui.theme.PhoneTheme
import app.telefon.ui.theme.WType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Incoming and in-call screen; shown over the lock screen. */
class CallActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
enableEdgeToEdge()
setContent {
val calls by CallManager.state.collectAsState()
val audio by CallManager.audio.collectAsState()
LaunchedEffect(calls) { if (calls.isEmpty() || calls.all { it.phase == Phase.DISCONNECTED }) { delay(800); if (CallManager.state.value.none { it.phase != Phase.DISCONNECTED }) finish() } }
PhoneTheme {
val ringing = calls.firstOrNull { it.phase == Phase.RINGING }
val main = calls.firstOrNull { it.phase != Phase.RINGING } ?: ringing
when {
main == null -> Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface))
// a second call while talking: answer replaces holding the first, which the system does for us
ringing != null -> IncomingScreen(ringing, onAnswer = { CallManager.answer(ringing.id) }, onDecline = { CallManager.reject(ringing.id) },
onMessage = { text -> CallManager.reject(ringing.id, text) })
else -> InCallScreen(main, audio, calls.count { it.phase != Phase.DISCONNECTED })
}
}
}
}
companion object {
fun intent(context: Context) = Intent(context, CallActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
fun pending(context: Context): PendingIntent = PendingIntent.getActivity(context, 0, intent(context), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
}
@Composable
private fun CallerHeader(call: CallUi, status: String, avatarSize: Int) {
val ctx = LocalContext.current
val who = remember(call.number) { Contacts.lookup(ctx, call.number) }
Column(Modifier.fillMaxWidth().statusBarsPadding().padding(top = 48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(status, style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
Text(who?.first ?: Fmt.number(call.number).ifEmpty { stringResource(R.string.unknown) }, style = WType.caller, color = MaterialTheme.colorScheme.onSurface)
if (who != null) Text(Fmt.number(call.number), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(32.dp))
Avatar(who?.first, who?.second, avatarSize.dp, cookie = true)
}
}
@Composable
fun IncomingScreen(call: CallUi, onAnswer: () -> Unit, onDecline: () -> Unit, onMessage: (String) -> Unit) {
var replies by remember { mutableStateOf(false) }
val quick = listOf(R.string.reply_1, R.string.reply_2, R.string.reply_3).map { stringResource(it) }
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally) {
CallerHeader(call, stringResource(R.string.incoming_call), 200)
Spacer(Modifier.weight(1f))
Box {
Row(Modifier.clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceContainerLowest).clickable { replies = true }.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Sms, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.message), style = WType.bodySm)
}
DropdownMenu(replies, { replies = false }) { quick.forEach { q -> DropdownMenuItem(text = { Text(q) }, onClick = { replies = false; onMessage(q) }) } }
}
Spacer(Modifier.height(32.dp))
SlideToAnswer(onAnswer, onDecline)
Spacer(Modifier.height(40.dp))
}
}
/** Ablehnen ← ☎ → Annehmen */
@Composable
fun SlideToAnswer(onAnswer: () -> Unit, onDecline: () -> Unit) {
val scope = rememberCoroutineScope()
val x = remember { Animatable(0f) }
val decline = stringResource(R.string.decline); val answer = stringResource(R.string.answer)
BoxWithConstraints(
Modifier.padding(horizontal = 24.dp).fillMaxWidth().height(76.dp).clip(RoundedCornerShape(38.dp)).background(MaterialTheme.colorScheme.surfaceContainer)
.semantics { customActions = listOf(CustomAccessibilityAction(answer) { onAnswer(); true }, CustomAccessibilityAction(decline) { onDecline(); true }) },
) {
val max = with(LocalDensity.current) { (maxWidth / 2 - 44.dp).toPx() }
Row(Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(decline, style = WType.body, modifier = Modifier.clickable(onClick = onDecline))
Text(answer, style = WType.body, modifier = Modifier.clickable(onClick = onAnswer))
}
Box(
Modifier.align(Alignment.Center).offset { IntOffset(x.value.roundToInt(), 0) }.size(88.dp, 64.dp).clip(RoundedCornerShape(32.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.pointerInput(max) {
detectHorizontalDragGestures(onDragEnd = {
scope.launch {
when { x.value > max * .5f -> { x.snapTo(max); onAnswer() }; x.value < -max * .5f -> { x.snapTo(-max); onDecline() }; else -> x.animateTo(0f, spring(.6f)) }
}
}) { _, d -> scope.launch { x.snapTo((x.value + d).coerceIn(-max, max)) } }
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Rounded.Phone, null, tint = LocalCallColors.current.accept) }
}
}
@Composable
fun InCallScreen(call: CallUi, audio: AudioUi, count: Int, nowOverride: Long? = null) {
val now = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(Unit) { while (true) { now.longValue = System.currentTimeMillis(); delay(1000) } }
var keypad by remember { mutableStateOf(false) }
var more by remember { mutableStateOf(false) }
var dtmf by remember { mutableStateOf("") }
val status = when (call.phase) {
Phase.DIALING -> stringResource(R.string.calling)
Phase.HOLDING -> stringResource(R.string.on_hold)
Phase.DISCONNECTED -> stringResource(R.string.call_ended)
else -> call.connectedAt?.let { Fmt.duration(now.longValue - it) } ?: ""
}
val cc = LocalCallColors.current
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally) {
if (keypad) {
Column(Modifier.statusBarsPadding().padding(top = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(dtmf.ifEmpty { " " }, style = WType.dial)
Text(status, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.weight(1f))
DialPad { c -> dtmf += c; CallManager.dtmf(call.id, c) }
} else {
CallerHeader(call, status, 140)
Spacer(Modifier.weight(1f))
}
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)).background(MaterialTheme.colorScheme.surfaceContainerLow).padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
Toggle(Icons.Rounded.Dialpad, stringResource(R.string.keypad), keypad) { keypad = !keypad }
Toggle(Icons.Rounded.MicOff, stringResource(R.string.mute), audio.muted) { CallManager.setMuted(!audio.muted) }
val bt = audio.supported and CallAudioState.ROUTE_BLUETOOTH != 0
val speaker = audio.route == CallAudioState.ROUTE_SPEAKER
Toggle(Icons.AutoMirrored.Rounded.VolumeUp, stringResource(R.string.speaker), speaker) {
CallManager.setRoute(if (speaker) CallAudioState.ROUTE_WIRED_OR_EARPIECE else CallAudioState.ROUTE_SPEAKER)
}
Box {
Toggle(Icons.Rounded.MoreVert, stringResource(R.string.more), more) { more = true }
DropdownMenu(more, { more = false }) {
if (call.canHold) DropdownMenuItem(
text = { Text(stringResource(if (call.phase == Phase.HOLDING) R.string.resume else R.string.hold)) },
leadingIcon = { Icon(Icons.Rounded.Pause, null) },
onClick = { more = false; CallManager.hold(call.id, call.phase != Phase.HOLDING) },
)
if (bt) DropdownMenuItem(text = { Text(stringResource(R.string.bluetooth)) }, leadingIcon = { Icon(Icons.Rounded.Bluetooth, null) },
onClick = { more = false; CallManager.setRoute(CallAudioState.ROUTE_BLUETOOTH) })
}
}
}
Spacer(Modifier.height(24.dp))
Box(Modifier.size(160.dp, 64.dp).clip(RoundedCornerShape(32.dp)).background(cc.hangup).clickable { CallManager.hangUp(call.id) }, contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.CallEnd, stringResource(R.string.hang_up), tint = cc.onHangup, modifier = Modifier.size(32.dp))
}
}
}
}
@Composable
private fun Toggle(icon: ImageVector, label: String, on: Boolean, onClick: () -> Unit) {
val s = MaterialTheme.colorScheme
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(76.dp)) {
Box(Modifier.size(64.dp).clip(CircleShape).background(if (on) s.primary else s.surfaceContainerLowest).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = if (on) s.onPrimary else s.onSurface)
}
Spacer(Modifier.height(6.dp))
Text(label, style = WType.label, color = s.onSurfaceVariant, maxLines = 1)
}
}
@@ -0,0 +1,78 @@
package app.telefon.call
import android.telecom.Call
import android.telecom.CallAudioState
import android.telecom.VideoProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class Phase { RINGING, DIALING, ACTIVE, HOLDING, DISCONNECTED }
data class CallUi(
val id: String,
val number: String,
val phase: Phase,
/** System.currentTimeMillis() when it became active, for the timer */
val connectedAt: Long?,
val canHold: Boolean,
)
data class AudioUi(val muted: Boolean = false, val route: Int = CallAudioState.ROUTE_EARPIECE, val supported: Int = 0)
/**
* The calls the system gives our InCallService, as UI state. All commands go through here,
* so the notification, the call screen and the service never disagree.
*/
object CallManager {
private val calls = mutableListOf<Call>()
private val _state = MutableStateFlow<List<CallUi>>(emptyList())
val state: StateFlow<List<CallUi>> = _state
private val _audio = MutableStateFlow(AudioUi())
val audio: StateFlow<AudioUi> = _audio
internal var service: CallService? = null
private val connectedAt = mutableMapOf<Call, Long>()
private val callback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) = publish()
override fun onDetailsChanged(call: Call, details: Call.Details) = publish()
}
internal fun added(call: Call) { calls += call; call.registerCallback(callback); publish() }
internal fun removed(call: Call) { call.unregisterCallback(callback); calls -= call; connectedAt.remove(call); publish() }
internal fun audioChanged(a: CallAudioState) { _audio.value = AudioUi(a.isMuted, a.route, a.supportedRouteMask) }
private fun key(c: Call) = Integer.toHexString(System.identityHashCode(c))
private fun find(id: String) = calls.firstOrNull { key(it) == id }
private fun phaseOf(c: Call) = when (c.details.state) {
Call.STATE_RINGING -> Phase.RINGING
Call.STATE_DIALING, Call.STATE_CONNECTING, Call.STATE_SELECT_PHONE_ACCOUNT, Call.STATE_PULLING_CALL -> Phase.DIALING
Call.STATE_ACTIVE -> Phase.ACTIVE
Call.STATE_HOLDING -> Phase.HOLDING
else -> Phase.DISCONNECTED
}
private fun publish() {
_state.value = calls.map { c ->
val phase = phaseOf(c)
if (phase == Phase.ACTIVE && c !in connectedAt) connectedAt[c] = c.details.connectTimeMillis.takeIf { it > 0 } ?: System.currentTimeMillis()
CallUi(
id = key(c),
number = c.details.handle?.schemeSpecificPart.orEmpty(),
phase = phase,
connectedAt = connectedAt[c],
canHold = c.details.can(Call.Details.CAPABILITY_HOLD),
)
}
service?.onCallsChanged(_state.value)
}
fun answer(id: String) = find(id)?.answer(VideoProfile.STATE_AUDIO_ONLY)
fun reject(id: String, message: String? = null) = find(id)?.reject(message != null, message)
fun hangUp(id: String) = find(id)?.disconnect()
fun hold(id: String, on: Boolean) = find(id)?.let { if (on) it.hold() else it.unhold() }
fun dtmf(id: String, c: Char) = find(id)?.let { it.playDtmfTone(c); it.stopDtmfTone() }
fun setMuted(m: Boolean) = service?.setMuted(m)
@Suppress("DEPRECATION")
fun setRoute(route: Int) = service?.setAudioRoute(route)
}
@@ -0,0 +1,120 @@
package app.telefon.call
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import android.telecom.Call
import android.telecom.CallAudioState
import android.telecom.InCallService
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import app.telefon.R
import app.telefon.data.Contacts
/** Bound by the system while calls exist, once this app is the default phone app. */
class CallService : InCallService() {
private var proximity: PowerManager.WakeLock? = null
override fun onCreate() { super.onCreate(); CallManager.service = this; channels(this) }
override fun onDestroy() { CallManager.service = null; releaseProximity(); super.onDestroy() }
override fun onCallAdded(call: Call) {
CallManager.added(call)
if (call.details.state == Call.STATE_RINGING) showIncoming(call) else startActivity(CallActivity.intent(this))
}
override fun onCallRemoved(call: Call) = CallManager.removed(call)
@Deprecated("CallEndpoint API on 34+, the audio state still works")
override fun onCallAudioStateChanged(audioState: CallAudioState) {
CallManager.audioChanged(audioState)
updateProximity(audioState.route)
}
internal fun onCallsChanged(calls: List<CallUi>) {
val nm = getSystemService(NotificationManager::class.java)
val ringing = calls.firstOrNull { it.phase == Phase.RINGING }
val current = calls.firstOrNull { it.phase != Phase.RINGING && it.phase != Phase.DISCONNECTED }
when {
ringing != null -> Unit // incoming notification already shown
current != null -> nm.notify(ID, ongoing(current))
else -> { nm.cancel(ID); releaseProximity() }
}
if (ringing == null && current == null) nm.cancel(ID)
}
private fun showIncoming(call: Call) {
val number = call.details.handle?.schemeSpecificPart.orEmpty()
val name = Contacts.lookupName(this, number) ?: number.ifEmpty { getString(R.string.unknown) }
val full = CallActivity.pending(this)
val person = Person.Builder().setName(name).setImportant(true).build()
val n = NotificationCompat.Builder(this, CH_INCOMING).setSmallIcon(R.drawable.ic_stat_call)
.setContentTitle(name).setContentText(getString(R.string.incoming_call))
.setCategory(NotificationCompat.CATEGORY_CALL).setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true).setFullScreenIntent(full, true).setContentIntent(full)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(person, action(ACTION_DECLINE), action(ACTION_ANSWER)))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build()
getSystemService(NotificationManager::class.java).notify(ID, n)
}
private fun ongoing(c: CallUi): Notification {
val name = Contacts.lookupName(this, c.number) ?: c.number
val person = Person.Builder().setName(name).build()
return NotificationCompat.Builder(this, CH_ONGOING).setSmallIcon(R.drawable.ic_stat_call)
.setContentTitle(name).setContentIntent(CallActivity.pending(this)).setOngoing(true).setSilent(true)
.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, action(ACTION_HANGUP)))
.apply { c.connectedAt?.let { setUsesChronometer(true).setWhen(it).setShowWhen(true) } }
.setCategory(NotificationCompat.CATEGORY_CALL).build()
}
private fun action(a: String) = PendingIntent.getBroadcast(
this, a.hashCode(), Intent(this, CallActionReceiver::class.java).setAction(a), PendingIntent.FLAG_IMMUTABLE,
)
/** Screen off when the phone is at the ear, only on the earpiece. */
@SuppressLint("WakelockTimeout")
private fun updateProximity(route: Int) {
val inCall = CallManager.state.value.any { it.phase == Phase.ACTIVE || it.phase == Phase.DIALING }
if (route == CallAudioState.ROUTE_EARPIECE && inCall) {
if (proximity == null) proximity = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "telefon:proximity").also { it.acquire() }
} else releaseProximity()
}
private fun releaseProximity() { proximity?.takeIf { it.isHeld }?.release(); proximity = null }
companion object {
const val ID = 1
const val CH_INCOMING = "incoming"
const val CH_ONGOING = "ongoing"
const val ACTION_ANSWER = "app.telefon.ANSWER"
const val ACTION_DECLINE = "app.telefon.DECLINE"
const val ACTION_HANGUP = "app.telefon.HANGUP"
fun channels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannels(listOf(
// the platform plays the ringtone for the default dialer; our channel stays silent
NotificationChannel(CH_INCOMING, context.getString(R.string.ch_incoming), NotificationManager.IMPORTANCE_HIGH).apply { setSound(null, null) },
NotificationChannel(CH_ONGOING, context.getString(R.string.ch_ongoing), NotificationManager.IMPORTANCE_LOW),
))
}
}
}
class CallActionReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val ringing = CallManager.state.value.firstOrNull { it.phase == Phase.RINGING }
val current = CallManager.state.value.firstOrNull { it.phase != Phase.RINGING }
when (intent.action) {
CallService.ACTION_ANSWER -> ringing?.let { CallManager.answer(it.id); context.startActivity(CallActivity.intent(context)) }
CallService.ACTION_DECLINE -> ringing?.let { CallManager.reject(it.id) }
CallService.ACTION_HANGUP -> current?.let { CallManager.hangUp(it.id) }
}
}
}
@@ -0,0 +1,159 @@
package app.telefon.data
import android.Manifest
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.CallLog
import android.provider.ContactsContract
import android.telephony.PhoneNumberUtils
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import java.util.Locale
enum class Direction { INCOMING, OUTGOING, MISSED, REJECTED, BLOCKED, VOICEMAIL }
data class CallEntry(
val id: Long, val number: String, val name: String?, val photo: Uri?, val direction: Direction,
val date: Long, val durationS: Long, val label: String?, val count: Int = 1,
)
data class Person(val id: Long, val lookupKey: String, val name: String, val photo: Uri?, val starred: Boolean, val phones: List<Phone>)
data class Phone(val number: String, val label: String)
private fun Context.has(p: String) = ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
/** Emits again whenever the given content changes. */
private fun <T> ContentResolver.observe(uri: Uri, load: () -> T): Flow<T> = callbackFlow {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) { trySend(load()) }
}
registerContentObserver(uri, true, obs)
trySend(load())
awaitClose { unregisterContentObserver(obs) }
}.flowOn(Dispatchers.IO)
object Contacts {
fun lookupName(context: Context, number: String): String? = lookup(context, number)?.first
/** name and photo for a number (PhoneLookup handles formatting differences) */
fun lookup(context: Context, number: String): Pair<String, Uri?>? {
if (number.isBlank() || !context.has(Manifest.permission.READ_CONTACTS)) return null
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
return runCatching {
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME, ContactsContract.PhoneLookup.PHOTO_URI), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) to c.getString(1)?.let(Uri::parse) else null
}
}.getOrNull()
}
fun callLog(context: Context): Flow<List<CallEntry>> = context.contentResolver.observe(CallLog.Calls.CONTENT_URI) { loadLog(context) }
private fun loadLog(context: Context): List<CallEntry> {
if (!context.has(Manifest.permission.READ_CALL_LOG)) return emptyList()
val out = mutableListOf<CallEntry>()
val cols = arrayOf(CallLog.Calls._ID, CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.CACHED_PHOTO_URI,
CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.CACHED_NUMBER_LABEL, CallLog.Calls.CACHED_NUMBER_TYPE)
runCatching {
context.contentResolver.query(CallLog.Calls.CONTENT_URI, cols, null, null, "${CallLog.Calls.DATE} DESC LIMIT 300")?.use { c ->
while (c.moveToNext()) {
val type = c.getInt(4)
val labelType = c.getInt(8)
val label = c.getString(7) ?: if (labelType != 0) ContactsContract.CommonDataKinds.Phone.getTypeLabel(context.resources, labelType, "").toString() else null
out += CallEntry(
id = c.getLong(0), number = c.getString(1).orEmpty(), name = c.getString(2)?.takeIf { it.isNotBlank() },
photo = c.getString(3)?.let(Uri::parse),
direction = when (type) {
CallLog.Calls.OUTGOING_TYPE -> Direction.OUTGOING
CallLog.Calls.MISSED_TYPE -> Direction.MISSED
CallLog.Calls.REJECTED_TYPE -> Direction.REJECTED
CallLog.Calls.BLOCKED_TYPE -> Direction.BLOCKED
CallLog.Calls.VOICEMAIL_TYPE -> Direction.VOICEMAIL
else -> Direction.INCOMING
},
date = c.getLong(5), durationS = c.getLong(6), label = label,
)
}
}
}
// consecutive calls with the same number and direction collapse into one row with a count
return out.fold(mutableListOf<CallEntry>()) { acc, e ->
val last = acc.lastOrNull()
if (last != null && same(last.number, e.number) && last.direction == e.direction && sameDay(last.date, e.date)) acc[acc.lastIndex] = last.copy(count = last.count + 1)
else acc += e
acc
}
}
private fun same(a: String, b: String) = PhoneNumberUtils.areSamePhoneNumber(a, b, Locale.getDefault().country.ifEmpty { "DE" })
private fun sameDay(a: Long, b: Long) = a / 86_400_000 == b / 86_400_000
fun people(context: Context): Flow<List<Person>> = context.contentResolver.observe(ContactsContract.Contacts.CONTENT_URI) { loadPeople(context) }
private fun loadPeople(context: Context): List<Person> {
if (!context.has(Manifest.permission.READ_CONTACTS)) return emptyList()
val phones = mutableMapOf<Long, MutableList<Phone>>()
runCatching {
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.LABEL),
null, null, null,
)?.use { c ->
while (c.moveToNext()) {
val label = ContactsContract.CommonDataKinds.Phone.getTypeLabel(context.resources, c.getInt(2), c.getString(3) ?: "").toString()
phones.getOrPut(c.getLong(0)) { mutableListOf() }.let { list -> val n = c.getString(1).orEmpty(); if (list.none { same(it.number, n) }) list += Phone(n, label) }
}
}
}
val out = mutableListOf<Person>()
runCatching {
context.contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
arrayOf(ContactsContract.Contacts._ID, ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.PHOTO_URI, ContactsContract.Contacts.STARRED),
"${ContactsContract.Contacts.HAS_PHONE_NUMBER} = 1", null, "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE LOCALIZED ASC",
)?.use { c ->
while (c.moveToNext()) {
val id = c.getLong(0)
out += Person(id, c.getString(1).orEmpty(), c.getString(2).orEmpty(), c.getString(3)?.let(Uri::parse), c.getInt(4) == 1, phones[id].orEmpty())
}
}
}
return out
}
fun setStarred(context: Context, id: Long, starred: Boolean) = runCatching {
context.contentResolver.update(
ContactsContract.Contacts.CONTENT_URI, android.content.ContentValues().apply { put(ContactsContract.Contacts.STARRED, if (starred) 1 else 0) },
"${ContactsContract.Contacts._ID} = ?", arrayOf(id.toString()),
)
}
fun deleteLogEntries(context: Context, number: String) = runCatching {
context.contentResolver.delete(CallLog.Calls.CONTENT_URI, "${CallLog.Calls.NUMBER} = ?", arrayOf(number))
}
}
/** T9: 2=abc, 3=def … Matches the digits against name word starts and against the number. */
object T9 {
private val map = mapOf('a' to '2', 'b' to '2', 'c' to '2', 'ä' to '2', 'd' to '3', 'e' to '3', 'f' to '3', 'g' to '4', 'h' to '4', 'i' to '4',
'j' to '5', 'k' to '5', 'l' to '5', 'm' to '6', 'n' to '6', 'o' to '6', 'ö' to '6', 'p' to '7', 'q' to '7', 'r' to '7', 's' to '7', 'ß' to '7',
't' to '8', 'u' to '8', 'v' to '8', 'ü' to '8', 'w' to '9', 'x' to '9', 'y' to '9', 'z' to '9')
fun digits(name: String) = name.lowercase().map { map[it] ?: if (it.isDigit()) it else ' ' }.joinToString("")
fun matches(p: Person, input: String): Boolean {
if (input.isEmpty()) return false
val words = digits(p.name).split(' ').filter { it.isNotEmpty() }
val joined = words.joinToString("")
return words.any { it.startsWith(input) } || joined.startsWith(input) ||
p.phones.any { it.number.filter(Char::isDigit).contains(input) }
}
}
@@ -0,0 +1,74 @@
package app.telefon.ui.components
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** 12-wave scalloped circle for the call screens. */
object CookieShape : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val c = Offset(size.width / 2, size.height / 2); val r = size.minDimension / 2 * .94f
val p = Path()
for (i in 0..180) {
val a = 2 * PI * i / 180
val rr = r * (1 + .05f * cos(12 * a).toFloat())
val x = c.x + rr * cos(a).toFloat(); val y = c.y + rr * sin(a).toFloat()
if (i == 0) p.moveTo(x, y) else p.lineTo(x, y)
}
p.close()
return Outline.Generic(p)
}
}
private val palette = listOf(0xFFD3E3FD, 0xFFC4EED0, 0xFFFFDAD6, 0xFFE9DDFF, 0xFFFFE08B, 0xFFC2E7FF).map { Color(it) }
private val onPalette = listOf(0xFF041E49, 0xFF072711, 0xFF410002, 0xFF21005D, 0xFF241A00, 0xFF001E2F).map { Color(it) }
@Composable
fun Avatar(name: String?, photo: Uri?, size: Dp, cookie: Boolean = false, modifier: Modifier = Modifier) {
val ctx = LocalContext.current
val bmp = produceState<ImageBitmap?>(null, photo) {
value = photo?.let { u -> withContext(Dispatchers.IO) { runCatching { ctx.contentResolver.openInputStream(u)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() } }.getOrNull() } }
}.value
val shape = if (cookie) CookieShape else CircleShape
val i = abs((name ?: "").hashCode()) % palette.size
Box(modifier.size(size).clip(shape).background(palette[i]), contentAlignment = Alignment.Center) {
when {
bmp != null -> Image(bmp, null, Modifier.size(size), contentScale = ContentScale.Crop)
!name.isNullOrBlank() && name.first().isLetter() -> Text(name.first().uppercase(), color = onPalette[i], fontSize = (size.value * .42f).sp)
else -> Icon(Icons.Rounded.Person, null, tint = onPalette[i], modifier = Modifier.size(size * .55f))
}
}
}
@@ -0,0 +1,16 @@
package app.telefon.ui.components
import android.telephony.PhoneNumberUtils
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
object Fmt {
fun number(n: String): String = if (n.isBlank()) "" else PhoneNumberUtils.formatNumber(n, Locale.getDefault().country.ifEmpty { "DE" }) ?: n
fun duration(ms: Long): String { val s = ms / 1000; return if (s >= 3600) "%d:%02d:%02d".format(s / 3600, s / 60 % 60, s % 60) else "%d:%02d".format(s / 60, s % 60) }
fun time(at: Long): String = Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).toLocalTime().format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
fun day(at: Long): LocalDate = Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).toLocalDate()
}
@@ -0,0 +1,161 @@
package app.telefon.ui.components
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.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
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.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.telefon.ui.theme.WSpace
import app.telefon.ui.theme.WType
/*
* Settings in the style of current Pixel system settings: grouped tiles with large outer
* corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries,
* choices in dialogs.
*/
private val Outer = 24.dp
private val Inner = 4.dp
private fun tileShape(index: Int, count: Int): Shape = when {
count == 1 -> RoundedCornerShape(Outer)
index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner)
index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer)
else -> RoundedCornerShape(Inner)
}
class GroupScope internal constructor() {
internal val items = mutableListOf<@Composable (Shape) -> Unit>()
fun item(content: @Composable (Shape) -> Unit) { items += content }
}
@Composable
fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) {
val scope = GroupScope().apply(content)
Column(modifier.padding(horizontal = WSpace.s16)) {
if (title != null) {
Text(
title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() },
)
} else Spacer(Modifier.padding(top = WSpace.s16))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) }
}
}
}
@Composable
private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp),
contentAlignment = Alignment.CenterStart,
) { content() }
}
@Composable
private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) {
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp))
}
Spacer(Modifier.width(WSpace.s16))
}
Column(Modifier.weight(1f)) {
Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface)
if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() }
}
}
@Composable
fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) }
}
@Composable
fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) {
Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) {
RowContent(icon, title, summary) {
Switch(
checked, onCheckedChange = null,
thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null,
)
}
}
}
/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */
@Composable
fun <T> SettingsChoice(shape: Shape, title: String, options: List<T>, selected: T, label: @Composable (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) {
var open by remember { mutableStateOf(false) }
SettingsItem(shape, title, label(selected), icon, onClick = { open = true })
if (open) {
AlertDialog(
onDismissRequest = { open = false },
title = { Text(title) },
text = {
Column(Modifier.selectableGroup()) {
options.forEach { o ->
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
.selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false }
.padding(vertical = WSpace.s12, horizontal = WSpace.s4),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(o == selected, onClick = null)
Spacer(Modifier.width(WSpace.s16))
Text(label(o), style = WType.body)
}
}
}
},
confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } },
)
}
}
/** Free content inside a group tile (swatches, sliders). */
@Composable
fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) {
Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) }
}
@@ -0,0 +1,337 @@
package app.telefon.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material.icons.automirrored.rounded.CallMade
import androidx.compose.material.icons.automirrored.rounded.CallMissed
import androidx.compose.material.icons.automirrored.rounded.CallReceived
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material.icons.rounded.Voicemail
import androidx.compose.material.icons.outlined.Sms
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.telefon.R
import app.telefon.data.CallEntry
import app.telefon.data.Direction
import app.telefon.data.Person
import app.telefon.data.T9
import app.telefon.ui.components.Avatar
import app.telefon.ui.components.Fmt
import app.telefon.ui.theme.LocalCallColors
import app.telefon.ui.theme.WType
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
enum class Filter { ALL, MISSED, CONTACTS }
private fun groupShape(i: Int, n: Int): Shape = when {
n == 1 -> RoundedCornerShape(24.dp)
i == 0 -> RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp)
i == n - 1 -> RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp)
else -> RoundedCornerShape(4.dp)
}
/** S01 */
@Composable
fun HomeScreen(
log: List<CallEntry>, people: List<Person>, pad: PaddingValues,
onCall: (String) -> Unit, onOpenNumber: (String) -> Unit, onOpenPerson: (Person) -> Unit, onSearch: () -> Unit,
) {
var filter by rememberSaveable { mutableStateOf(Filter.ALL) }
val scheme = MaterialTheme.colorScheme
val res = LocalContext.current.resources
val favorites = people.filter { it.starred }
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding() + 16.dp)) {
item {
Row(
Modifier.padding(16.dp).fillMaxWidth().height(56.dp).clip(RoundedCornerShape(28.dp)).background(scheme.surfaceContainer).clickable(onClick = onSearch).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(R.string.search_contacts), style = WType.body, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
Icon(Icons.Rounded.Search, null, tint = scheme.onSurfaceVariant)
}
}
item {
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(Filter.entries) { f ->
FilterChip(filter == f, { filter = f }, label = {
Text(stringResource(when (f) { Filter.ALL -> R.string.f_all; Filter.MISSED -> R.string.f_missed; Filter.CONTACTS -> R.string.f_contacts }))
})
}
}
}
if (filter == Filter.CONTACTS) {
items(people, key = { "p" + it.id }) { p ->
Row(Modifier.fillMaxWidth().clickable { onOpenPerson(p) }.padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp)); Text(p.name, style = WType.body, modifier = Modifier.weight(1f))
p.phones.firstOrNull()?.let { IconButton(onClick = { onCall(it.number) }) { Icon(Icons.Rounded.Call, stringResource(R.string.call)) } }
}
}
if (people.isEmpty()) item { Empty(stringResource(R.string.no_contacts)) }
return@LazyColumn
}
if (favorites.isNotEmpty() && filter == Filter.ALL) {
item { Header(stringResource(R.string.favorites)) }
item {
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(favorites, key = { it.id }) { p ->
Column(Modifier.width(64.dp).combinedClickable(onClick = { p.phones.firstOrNull()?.let { onCall(it.number) } }, onLongClick = { onOpenPerson(p) }),
horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(p.name, p.photo, 56.dp)
Text(p.name.substringBefore(' '), style = WType.label, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
}
}
}
}
val shown = if (filter == Filter.MISSED) log.filter { it.direction == Direction.MISSED } else log
if (shown.isEmpty()) item { Empty(stringResource(if (filter == Filter.MISSED) R.string.no_missed else R.string.no_calls)) }
shown.groupBy { Fmt.day(it.date) }.forEach { (day, calls) ->
item(key = "h$day") {
Header(when (day) { LocalDate.now() -> res.getString(R.string.today); LocalDate.now().minusDays(1) -> res.getString(R.string.yesterday); else -> day.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) })
}
itemsIndexed(calls, key = { _, c -> c.id }) { i, c ->
CallRow(c, groupShape(i, calls.size), { onCall(c.number) }) { onOpenNumber(c.number) }
}
}
}
}
@Composable private fun Header(t: String) = Text(t, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 24.dp, top = 20.dp, bottom = 8.dp))
@Composable private fun Empty(t: String) = Text(t, style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(48.dp))
@Composable
private fun CallRow(c: CallEntry, shape: Shape, onCall: () -> Unit, onOpen: () -> Unit) {
val scheme = MaterialTheme.colorScheme
val missed = c.direction == Direction.MISSED
val color = if (missed) LocalCallColors.current.missed else scheme.onSurfaceVariant
val icon = when (c.direction) {
Direction.OUTGOING -> Icons.AutoMirrored.Rounded.CallMade
Direction.MISSED -> Icons.AutoMirrored.Rounded.CallMissed
Direction.REJECTED, Direction.BLOCKED -> Icons.Rounded.Block
Direction.VOICEMAIL -> Icons.Rounded.Voicemail
Direction.INCOMING -> Icons.AutoMirrored.Rounded.CallReceived
}
val typeText = stringResource(when (c.direction) {
Direction.OUTGOING -> R.string.d_out; Direction.MISSED -> R.string.d_missed; Direction.REJECTED -> R.string.d_rejected
Direction.BLOCKED -> R.string.d_blocked; Direction.VOICEMAIL -> R.string.d_voicemail; Direction.INCOMING -> R.string.d_in
})
val title = (c.name ?: Fmt.number(c.number).ifEmpty { stringResource(R.string.unknown) }) + if (c.count > 1) " (${c.count})" else ""
Row(
Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(shape).background(scheme.surfaceContainerLowest).clickable(onClick = onOpen).padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp)
.semantics(mergeDescendants = true) { contentDescription = "$title, $typeText, ${Fmt.time(c.date)}" },
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(c.name, c.photo, 40.dp)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(title, style = WType.body, color = if (missed) color else scheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, Modifier.size(14.dp), tint = color)
Text(" " + (c.label?.takeIf { c.name != null } ?: typeText) + " · " + Fmt.time(c.date), style = WType.bodySm, color = color, maxLines = 1)
}
}
if (c.number.isNotBlank()) IconButton(onClick = onCall) { Icon(Icons.Rounded.Call, stringResource(R.string.call)) }
}
}
/** S02 */
@Composable
fun SearchScreen(people: List<Person>, pad: PaddingValues, onCall: (String) -> Unit, onOpenPerson: (Person) -> Unit) {
var q by rememberSaveable { mutableStateOf("") }
val hits = remember(q, people) {
if (q.isBlank()) people.take(40) else people.filter { p -> p.name.contains(q, true) || p.phones.any { it.number.filter(Char::isDigit).contains(q.filter(Char::isDigit).ifEmpty { "§" }) } }
}
Column(Modifier.fillMaxSize().padding(pad)) {
TextField(q, { q = it }, Modifier.fillMaxWidth().padding(16.dp).clip(RoundedCornerShape(28.dp)), placeholder = { Text(stringResource(R.string.search_contacts)) },
leadingIcon = { Icon(Icons.Rounded.Search, null) }, singleLine = true,
colors = TextFieldDefaults.colors(focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
LazyColumn {
if (q.any(Char::isDigit)) item {
Row(Modifier.fillMaxWidth().clickable { onCall(q) }.padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Call, null); Spacer(Modifier.width(16.dp)); Text(stringResource(R.string.call_number, q), style = WType.body)
}
}
items(hits, key = { it.id }) { p ->
Row(Modifier.fillMaxWidth().clickable { onOpenPerson(p) }.padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) { Text(p.name, style = WType.body); p.phones.firstOrNull()?.let { Text("${it.label} · ${Fmt.number(it.number)}", style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
}
}
}
}
}
/** 3×4 keys with letters; used by S03 and the in-call keypad. */
@Composable
fun DialPad(onKey: (Char) -> Unit) {
val letters = mapOf('2' to "ABC", '3' to "DEF", '4' to "GHI", '5' to "JKL", '6' to "MNO", '7' to "PQRS", '8' to "TUV", '9' to "WXYZ", '0' to "+")
val view = androidx.compose.ui.platform.LocalView.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 32.dp)) {
listOf("123", "456", "789", "*0#").forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
row.forEach { k ->
Column(
Modifier.weight(1f).height(68.dp).clip(RoundedCornerShape(34.dp)).background(MaterialTheme.colorScheme.surfaceContainer)
.clickable { view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP); onKey(k) },
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center,
) {
Text(k.toString(), style = WType.key)
letters[k]?.let { Text(it, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
}
}
}
/** S03 */
@Composable
fun KeypadScreen(people: List<Person>, pad: PaddingValues, initial: String, onCall: (String) -> Unit) {
var number by rememberSaveable(initial) { mutableStateOf(initial) }
val digits = number.filter(Char::isDigit)
val matches = remember(digits, people) { if (digits.isEmpty()) emptyList() else people.filter { T9.matches(it, digits) }.take(3) }
Column(Modifier.fillMaxSize().padding(pad), horizontalAlignment = Alignment.CenterHorizontally) {
Column(Modifier.weight(1f).fillMaxWidth()) {
matches.forEach { p ->
Row(Modifier.fillMaxWidth().clickable { p.phones.firstOrNull()?.let { onCall(it.number) } }.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp))
Column { Text(p.name, style = WType.body); p.phones.firstOrNull()?.let { Text(Fmt.number(it.number), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
}
}
}
Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(48.dp))
Text(Fmt.number(number).ifEmpty { number }, style = WType.dial, textAlign = TextAlign.Center, maxLines = 1, modifier = Modifier.weight(1f))
if (number.isNotEmpty()) Box(Modifier.size(48.dp).combinedClickable(onClick = { number = number.dropLast(1) }, onLongClick = { number = "" }), contentAlignment = Alignment.Center) {
Icon(Icons.AutoMirrored.Rounded.Backspace, stringResource(R.string.backspace))
} else Spacer(Modifier.width(48.dp))
}
Spacer(Modifier.height(16.dp))
DialPad { number += it }
Spacer(Modifier.height(16.dp))
val cc = LocalCallColors.current
Box(Modifier.size(72.dp).clip(CircleShape).background(cc.accept).clickable(enabled = number.isNotBlank()) { onCall(number) }, contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Call, stringResource(R.string.call), tint = cc.onAccept, modifier = Modifier.size(32.dp))
}
Spacer(Modifier.height(24.dp))
}
}
/** S04 */
@Composable
fun VoicemailScreen(number: String?, pad: PaddingValues, onCall: (String) -> Unit) {
Column(Modifier.fillMaxSize().padding(pad).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Rounded.Voicemail, null, Modifier.size(72.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text(if (number != null) stringResource(R.string.voicemail_number, Fmt.number(number)) else stringResource(R.string.voicemail_none), style = WType.body, textAlign = TextAlign.Center)
Spacer(Modifier.height(24.dp))
if (number != null) Button(onClick = { onCall(number) }) { Text(stringResource(R.string.voicemail_call)) }
}
}
/** S05: a contact, or just a number from the log. */
@Composable
fun DetailsScreen(
person: Person?, number: String?, log: List<CallEntry>, pad: PaddingValues,
onCall: (String) -> Unit, onSms: (String) -> Unit, onStar: (Person) -> Unit, onAddContact: (String) -> Unit, onOpenContacts: (Person) -> Unit,
) {
val numbers = person?.phones?.map { it.number } ?: listOfNotNull(number)
val calls = log.filter { e -> numbers.any { android.telephony.PhoneNumberUtils.compare(it, e.number) } }
LazyColumn(Modifier.fillMaxSize(), contentPadding = pad) {
item {
Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person?.name, person?.photo, 120.dp, cookie = true)
Spacer(Modifier.height(16.dp))
Text(person?.name ?: Fmt.number(number.orEmpty()), style = WType.caller.copy(fontSize = WType.title.fontSize * 1.3f), textAlign = TextAlign.Center)
Row(Modifier.padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
numbers.firstOrNull()?.let { n ->
Action(Icons.Rounded.Call, stringResource(R.string.call)) { onCall(n) }
Action(Icons.Outlined.Sms, stringResource(R.string.sms)) { onSms(n) }
}
if (person != null) Action(if (person.starred) Icons.Rounded.Star else Icons.Rounded.StarBorder, stringResource(R.string.favorite)) { onStar(person) }
else number?.let { Action(Icons.Outlined.PersonAdd, stringResource(R.string.add_contact)) { onAddContact(it) } }
}
}
}
person?.phones?.let { phones ->
itemsIndexed(phones) { i, p ->
Row(Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(groupShape(i, phones.size)).background(MaterialTheme.colorScheme.surfaceContainerLowest)
.clickable { onCall(p.number) }.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Text(Fmt.number(p.number), style = WType.body); Text(p.label, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) }
IconButton(onClick = { onSms(p.number) }) { Icon(Icons.Outlined.Sms, stringResource(R.string.sms)) }
}
}
item { androidx.compose.material3.TextButton(onClick = { onOpenContacts(person) }, modifier = Modifier.padding(start = 16.dp)) { Text(stringResource(R.string.open_in_contacts)) } }
}
if (calls.isNotEmpty()) item { Header(stringResource(R.string.history)) }
itemsIndexed(calls.take(30)) { i, c ->
Row(Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(groupShape(i, calls.take(30).size)).background(MaterialTheme.colorScheme.surfaceContainerLowest).padding(16.dp)) {
Text(c.date.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)) }, style = WType.bodySm, modifier = Modifier.weight(1f))
Text(if (c.durationS > 0) Fmt.duration(c.durationS * 1000) else "–", style = WType.bodySm)
}
}
}
}
@Composable
private fun Action(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(72.dp)) {
Box(Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.secondaryContainer).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = MaterialTheme.colorScheme.onSecondaryContainer)
}
Text(label, style = WType.label, modifier = Modifier.padding(top = 6.dp))
}
}
@@ -0,0 +1,39 @@
package app.telefon.ui.theme
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.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
/** Call colours stay fixed (green answer, red hang up) so they read the same on every wallpaper. */
@Immutable
data class CallColors(val accept: Color, val onAccept: Color, val hangup: Color, val onHangup: Color, val missed: Color)
private val LightCall = CallColors(WColor.accept, WColor.onAccept, WColor.hangup, WColor.onHangup, WColor.missed)
private val DarkCall = CallColors(WColor.darkAccept, WColor.darkOnAccept, WColor.darkHangup, WColor.darkOnHangup, WColor.darkMissed)
val LocalCallColors = staticCompositionLocalOf { LightCall }
@Composable
fun PhoneTheme(dynamic: Boolean = true, forceDark: Boolean? = null, content: @Composable () -> Unit) {
val dark = forceDark ?: isSystemInDarkTheme()
val ctx = LocalContext.current
val scheme = when {
dynamic && Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
dark -> darkColorScheme(primary = WColor.darkToggleOn, onPrimary = WColor.darkOnToggleOn, surface = WColor.darkBg, background = WColor.darkBg,
surfaceContainerLow = WColor.darkCard, surfaceContainerLowest = WColor.darkCard, surfaceContainer = WColor.darkToggle,
secondaryContainer = WColor.darkChipOn, onSecondaryContainer = WColor.darkOnChipOn, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted)
else -> lightColorScheme(primary = WColor.onChipOn, surface = WColor.bg, background = WColor.bg,
surfaceContainerLow = WColor.card, surfaceContainerLowest = WColor.card, surfaceContainer = WColor.slideTrack,
secondaryContainer = WColor.chipOn, onSecondaryContainer = WColor.onChipOn, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted)
}
CompositionLocalProvider(LocalCallColors provides if (dark) DarkCall else LightCall) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -0,0 +1,71 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.telefon.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object WColor {
val bg = Color(0xFFF6F7FB)
val card = Color(0xFFFFFFFF)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF44474E)
val missed = Color(0xFFBA1A1A)
val chipOn = Color(0xFFD3E3FD)
val onChipOn = Color(0xFF041E49)
val accept = Color(0xFF1E6B38)
val onAccept = Color(0xFFFFFFFF)
val hangup = Color(0xFFC5221F)
val onHangup = Color(0xFFFFFFFF)
val toggle = Color(0xFFFFFFFF)
val onToggle = Color(0xFF191C20)
val toggleOn = Color(0xFFD3E3FD)
val onToggleOn = Color(0xFF041E49)
val slideTrack = Color(0xFFE2E5EC)
val onSlideTrack = Color(0xFF191C20)
val darkBg = Color(0xFF111318)
val darkCard = Color(0xFF1D2024)
val darkText = Color(0xFFE2E2E9)
val darkTextMuted = Color(0xFFC4C6D0)
val darkMissed = Color(0xFFFFB4AB)
val darkChipOn = Color(0xFF2F4A72)
val darkOnChipOn = Color(0xFFD6E3FF)
val darkAccept = Color(0xFF6FDC8C)
val darkOnAccept = Color(0xFF00391A)
val darkHangup = Color(0xFFF2645A)
val darkOnHangup = Color(0xFF410002)
val darkToggle = Color(0xFF2B2F36)
val darkOnToggle = Color(0xFFE2E2E9)
val darkToggleOn = Color(0xFFA8C7FA)
val darkOnToggleOn = Color(0xFF062E6F)
val darkSlideTrack = Color(0xFF2B2F36)
val darkOnSlideTrack = Color(0xFFE2E2E9)
}
object WType {
val label = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
val caller = TextStyle(fontSize = 40.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
val dial = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val key = TextStyle(fontSize = 30.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
val s32 = 32.dp
}
object WRadius {
val cardOuter = 24.dp
val cardInner = 4.dp
val pill = 999.dp
}
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF1B5E3A" android:pathData="M0,0h108v108h-108z"/></vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFC4EED0" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:fillColor="#FF0B3D20" android:pathData="M40.5,34.5c1.6,-1.6 4.2,-1.4 5.5,0.4l4.2,5.8c1.1,1.5 1,3.6 -0.3,4.9l-2.6,2.6c1.9,4.4 5.8,8.4 10.3,10.4l2.6,-2.6c1.3,-1.3 3.4,-1.5 4.9,-0.4l5.8,4.2c1.8,1.3 2,3.9 0.4,5.5l-2.6,2.6c-2.4,2.4 -6.1,3.1 -9.2,1.7c-9.6,-4.3 -17.3,-12 -21.6,-21.6c-1.4,-3.1 -0.7,-6.8 1.7,-9.2z"/>
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF000000" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M40.5,34.5c1.6,-1.6 4.2,-1.4 5.5,0.4l4.2,5.8c1.1,1.5 1,3.6 -0.3,4.9l-2.6,2.6c1.9,4.4 5.8,8.4 10.3,10.4l2.6,-2.6c1.3,-1.3 3.4,-1.5 4.9,-0.4l5.8,4.2c1.8,1.3 2,3.9 0.4,5.5l-2.6,2.6c-2.4,2.4 -6.1,3.1 -9.2,1.7c-9.6,-4.3 -17.3,-12 -21.6,-21.6c-1.4,-3.1 -0.7,-6.8 1.7,-9.2z"/>
</vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M6.6,10.8c1.4,2.8 3.8,5.1 6.6,6.6l2.2,-2.2c0.3,-0.3 0.7,-0.4 1,-0.2 1.1,0.4 2.3,0.6 3.6,0.6 0.6,0 1,0.4 1,1V20c0,0.6 -0.4,1 -1,1 -9.4,0 -17,-7.6 -17,-17 0,-0.6 0.4,-1 1,-1h3.5c0.6,0 1,0.4 1,1 0,1.3 0.2,2.5 0.6,3.6 0.1,0.3 0,0.7 -0.2,1L6.6,10.8z"/></vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="tab_home">Start</string>
<string name="tab_keypad">Wähltasten</string>
<string name="tab_voicemail">Mailbox</string>
<string name="search">Suchen</string>
<string name="search_contacts">Kontakte durchsuchen</string>
<string name="back">Zurück</string>
<string name="f_all">Alle</string>
<string name="f_missed">Verpasst</string>
<string name="f_contacts">Kontakte</string>
<string name="favorites">Favoriten</string>
<string name="today">Heute</string>
<string name="yesterday">Gestern</string>
<string name="no_calls">Noch keine Anrufe</string>
<string name="no_missed">Keine verpassten Anrufe</string>
<string name="no_contacts">Keine Kontakte mit Telefonnummer</string>
<string name="unknown">Unbekannt</string>
<string name="call">Anrufen</string>
<string name="call_number">%1$s anrufen</string>
<string name="sms">SMS</string>
<string name="favorite">Favorit</string>
<string name="add_contact">Hinzufügen</string>
<string name="open_in_contacts">In Kontakte öffnen</string>
<string name="history">Anrufe</string>
<string name="backspace">Löschen</string>
<string name="d_in">Eingehend</string>
<string name="d_out">Ausgehend</string>
<string name="d_missed">Verpasst</string>
<string name="d_rejected">Abgelehnt</string>
<string name="d_blocked">Blockiert</string>
<string name="d_voicemail">Mailbox</string>
<string name="voicemail_number">Deine Mailbox: %1$s</string>
<string name="voicemail_none">Für diese SIM-Karte ist keine Mailbox-Nummer hinterlegt.</string>
<string name="voicemail_call">Mailbox anrufen</string>
<string name="default_title">Als Standard-Telefon-App festlegen</string>
<string name="default_text">Nur dann kann diese App Anrufe annehmen und anzeigen. Du kannst das jederzeit in den Android-Einstellungen ändern.</string>
<string name="incoming_call">Eingehender Anruf</string>
<string name="calling">Wird angerufen …</string>
<string name="on_hold">Gehalten</string>
<string name="call_ended">Anruf beendet</string>
<string name="decline">Ablehnen</string>
<string name="answer">Annehmen</string>
<string name="message">Nachricht</string>
<string name="reply_1">Ich kann gerade nicht sprechen.</string>
<string name="reply_2">Ich rufe dich gleich zurück.</string>
<string name="reply_3">Bin unterwegs, melde mich später.</string>
<string name="keypad">Tasten</string>
<string name="mute">Stumm</string>
<string name="speaker">Lautspr.</string>
<string name="more">Mehr</string>
<string name="hold">Halten</string>
<string name="resume">Fortsetzen</string>
<string name="bluetooth">Bluetooth</string>
<string name="hang_up">Auflegen</string>
<string name="ch_incoming">Eingehende Anrufe</string>
<string name="ch_ongoing">Laufende Anrufe</string>
</resources>
@@ -0,0 +1,52 @@
package app.telefon
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import app.telefon.call.AudioUi
import app.telefon.call.CallUi
import app.telefon.call.IncomingScreen
import app.telefon.call.InCallScreen
import app.telefon.call.Phase
import app.telefon.data.CallEntry
import app.telefon.data.Direction
import app.telefon.data.Person
import app.telefon.data.Phone
import app.telefon.ui.screens.HomeScreen
import app.telefon.ui.screens.KeypadScreen
import app.telefon.ui.theme.PhoneTheme
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Made-up people and numbers (+49 30 555 test range), no real data. */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "de-rDE-w412dp-h915dp-xxhdpi", application = android.app.Application::class)
class ScreenshotTest {
@get:Rule val compose = createComposeRule()
private val now = System.currentTimeMillis()
private val people = listOf("Alessa Brandt", "Ben Okafor", "Clara Wu", "David Roth", "Emil Sander").mapIndexed { i, n ->
Person(i.toLong(), "k$i", n, null, i < 5, listOf(Phone("+49305550${i}12", "Mobil")))
}
private val log = listOf(
CallEntry(1, "+4930555012", "Clara Wu", null, Direction.INCOMING, now - 600_000, 120, "Mobil"),
CallEntry(2, "+4930555099", null, null, Direction.MISSED, now - 3_600_000, 0, null, count = 2),
CallEntry(3, "+4930555013", "Ben Okafor", null, Direction.OUTGOING, now - 90_000_000, 300, "Mobil"),
CallEntry(4, "+4930555077", "Paketdienst", null, Direction.INCOMING, now - 95_000_000, 40, "Arbeit"),
)
private fun shot(name: String, dark: Boolean, c: @Composable () -> Unit) {
compose.setContent { PhoneTheme(dynamic = false, forceDark = dark) { c() } }
compose.onRoot().captureRoboImage("../../replica/telefon/clone-screens/$name.png")
}
@Test fun s01_home() = shot("S01-home-light", false) { HomeScreen(log, people, PaddingValues(), {}, {}, {}, {}) }
@Test fun s01_home_dark() = shot("S01-home-dark", true) { HomeScreen(log, people, PaddingValues(), {}, {}, {}, {}) }
@Test fun s03_keypad() = shot("S03-keypad-light", false) { KeypadScreen(people, PaddingValues(), "2525", {}) }
@Test fun s06_incoming() = shot("S06-incoming-light", false) { IncomingScreen(CallUi("a", "+4930555099", Phase.RINGING, null, true), {}, {}, {}) }
@Test fun s07_incall() = shot("S07-incall-dark", true) { InCallScreen(CallUi("a", "+4930555099", Phase.ACTIVE, now - 53_000, true), AudioUi(muted = true), 1, nowOverride = now) }
}