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
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
+93
View File
@@ -0,0 +1,93 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
id("io.github.takahirom.roborazzi")
}
android {
namespace = "app.telefon"
compileSdk = 36
defaultConfig {
applicationId = "app.telefon"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
val keyProps = Properties().apply {
val f = file(System.getProperty("user.home") + "/.android/nogoogleapps-release.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
signingConfigs {
if (keyProps.isNotEmpty()) create("release") {
storeFile = file(keyProps.getProperty("storeFile"))
storePassword = keyProps.getProperty("storePassword")
keyAlias = keyProps.getProperty("keyAlias")
keyPassword = keyProps.getProperty("keyPassword")
}
}
buildTypes {
debug {
// installs next to the release app from Obtainium
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Telefon (Debug)")
}
release {
resValue("string", "app_name", "Telefon")
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
testOptions { unitTests { isIncludeAndroidResources = true } }
}
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.11.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
implementation("androidx.room:room-runtime:2.7.0")
implementation("androidx.room:room-ktx:2.7.0")
ksp("androidx.room:room-compiler:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.15.1")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.46.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.46.1")
}
roborazzi { outputDir.set(rootProject.file("../replica/telefon/clone-screens")) }
View File
+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) }
}
+7
View File
@@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.13.0" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false
id("io.github.takahirom.roborazzi") version "1.46.1" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Telefon"
include(":app")