feat(settings): call the app "Clock" and add an app icon picker
The launcher and system label is now a plain, translatable "Clock"; the brand name Clockula stays on the About card and in crash reports via an untranslated app_brand_name string. Settings → Appearance → App icon chooses between the bloom mark and a new plain clock icon (the same dial and hands without the badge). The launcher entry moves off MainActivity onto two <activity-alias> components, exactly one enabled at a time, as Calendula does for its launcher name. The component-enabled state is the single source of truth, and switching enables the target before disabling the other so the launcher never sees zero entries.
This commit is contained in:
@@ -77,11 +77,39 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- The MAIN/LAUNCHER entry lives on the two <activity-alias>
|
||||||
|
below, so the launcher icon can be switched at runtime. -->
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- The launcher entry, once per icon: exactly one alias is enabled at a
|
||||||
|
time, toggled from Settings → Appearance → App icon
|
||||||
|
(LauncherIconManager). The bloom is the manifest default. -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".BloomIconAlias"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".PlainIconAlias"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_plain"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_plain_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<!-- The one exported door for android.provider.AlarmClock: every other
|
<!-- The one exported door for android.provider.AlarmClock: every other
|
||||||
app on the device drives Clockula through it. Guarded by the
|
app on the device drives Clockula through it. Guarded by the
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class ClockulaApp : Application() {
|
|||||||
CrashReporter.install(
|
CrashReporter.install(
|
||||||
this,
|
this,
|
||||||
CrashConfig(
|
CrashConfig(
|
||||||
appLabel = getString(R.string.app_name),
|
appLabel = getString(R.string.app_brand_name),
|
||||||
newIssueUrl = getString(R.string.report_issue_url),
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
chooseIssueUrl = getString(R.string.report_issue_url),
|
chooseIssueUrl = getString(R.string.report_issue_url),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.data.appicon
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** The launcher icon the app shows for itself. */
|
||||||
|
enum class LauncherIcon { BLOOM, PLAIN }
|
||||||
|
|
||||||
|
/** One component-enable change in an [iconWritePlan]. */
|
||||||
|
data class IconAliasChange(val icon: LauncherIcon, val enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret the `PlainIconAlias` component-enabled state as a [LauncherIcon].
|
||||||
|
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the plain icon is
|
||||||
|
* active; `DEFAULT` (never switched) and `DISABLED` both resolve to the
|
||||||
|
* manifest default, the bloom.
|
||||||
|
*/
|
||||||
|
fun launcherIconFor(plainAliasState: Int): LauncherIcon =
|
||||||
|
if (plainAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
|
LauncherIcon.PLAIN
|
||||||
|
} else {
|
||||||
|
LauncherIcon.BLOOM
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordered enable/disable steps to switch the launcher icon to [target].
|
||||||
|
* Always **enables the target alias first, then disables the other**: the two
|
||||||
|
* `setComponentEnabledSetting` calls are not atomic, and this order means the
|
||||||
|
* only transient a launcher can observe is a harmless two-entry state, never a
|
||||||
|
* zero-entry one that would briefly drop the app from the launcher.
|
||||||
|
*/
|
||||||
|
fun iconWritePlan(target: LauncherIcon): List<IconAliasChange> =
|
||||||
|
listOf(IconAliasChange(target, enabled = true)) +
|
||||||
|
LauncherIcon.entries.filter { it != target }.map { IconAliasChange(it, enabled = false) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the launcher icon between the bloom mark and a plain clock, as
|
||||||
|
* Calendula does for its launcher name. The seam keeps the settings
|
||||||
|
* ViewModel JVM-testable; [SystemLauncherIconManager] is the real one.
|
||||||
|
*/
|
||||||
|
interface LauncherIconManager {
|
||||||
|
/** The launcher icon currently in effect. Blocking: a binder round-trip. */
|
||||||
|
fun current(): LauncherIcon
|
||||||
|
|
||||||
|
/** Switch the launcher icon to [icon] (cheap if already active). Blocking. */
|
||||||
|
fun set(icon: LauncherIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables/disables the two `<activity-alias>` launcher entries. The
|
||||||
|
* component-enabled state is the single source of truth — there is no
|
||||||
|
* persisted preference — so [current] reads it straight from [PackageManager]
|
||||||
|
* and the two always agree.
|
||||||
|
*
|
||||||
|
* The decision logic lives in the pure [launcherIconFor] / [iconWritePlan]
|
||||||
|
* above (JVM-tested); this class is only the thin framework seam.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SystemLauncherIconManager @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) : LauncherIconManager {
|
||||||
|
private val packageManager: PackageManager get() = context.packageManager
|
||||||
|
|
||||||
|
// The package is the applicationId (carrying the .debug / .releasetest
|
||||||
|
// suffix), the class the namespace: the manifest's ".PlainIconAlias"
|
||||||
|
// resolves against the namespace. ComponentName(context, ".PlainIconAlias")
|
||||||
|
// would prepend the applicationId to the class too and fail on every
|
||||||
|
// suffixed build.
|
||||||
|
private fun component(icon: LauncherIcon): ComponentName {
|
||||||
|
val simpleName = when (icon) {
|
||||||
|
LauncherIcon.BLOOM -> "BloomIconAlias"
|
||||||
|
LauncherIcon.PLAIN -> "PlainIconAlias"
|
||||||
|
}
|
||||||
|
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun current(): LauncherIcon =
|
||||||
|
launcherIconFor(packageManager.getComponentEnabledSetting(component(LauncherIcon.PLAIN)))
|
||||||
|
|
||||||
|
override fun set(icon: LauncherIcon) {
|
||||||
|
for (change in iconWritePlan(icon)) {
|
||||||
|
val state = if (change.enabled) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
packageManager.setComponentEnabledSetting(component(change.icon), state, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** The app's namespace, not its applicationId. Kept in sync with `app/build.gradle.kts`. */
|
||||||
|
const val NAMESPACE = "de.jeanlucmakiola.clockula"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.data.di
|
||||||
|
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.LauncherIconManager
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.SystemLauncherIconManager
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** The launcher-icon switch behind Settings → Appearance → App icon. */
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class AppIconModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindLauncherIconManager(impl: SystemLauncherIconManager): LauncherIconManager
|
||||||
|
}
|
||||||
@@ -1,21 +1,53 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.settings
|
package de.jeanlucmakiola.clockula.ui.settings
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
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.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
|
||||||
/** How the app looks: the theme and wallpaper colours. */
|
/** How the app looks: the theme, wallpaper colours and launcher icon. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AppearanceScreen(
|
internal fun AppearanceScreen(
|
||||||
state: SettingsUiState.Loaded,
|
state: SettingsUiState.Loaded,
|
||||||
@@ -24,6 +56,8 @@ internal fun AppearanceScreen(
|
|||||||
) {
|
) {
|
||||||
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
var showTheme by rememberSaveable { mutableStateOf(false) }
|
var showTheme by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showAppIcon by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val launcherIcon by viewModel.launcherIcon.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
|
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -53,6 +87,51 @@ internal fun AppearanceScreen(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A launcher concern rather than in-app styling, so its own group.
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_app_icon),
|
||||||
|
summary = launcherIconLabel(launcherIcon),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showAppIcon = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAppIcon) {
|
||||||
|
// Both icons shown as previews; a tap applies at once and the picker
|
||||||
|
// stays open so the choice stays visible (FullScreenPicker's third rung).
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_app_icon),
|
||||||
|
onDismiss = { showAppIcon = false },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_app_icon_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
LauncherIcon.entries.forEach { option ->
|
||||||
|
AppIconOptionCard(
|
||||||
|
icon = option,
|
||||||
|
selected = launcherIcon == option,
|
||||||
|
onClick = { viewModel.onLauncherIconSelected(option) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
@@ -75,3 +154,91 @@ private fun themeModeLabel(mode: ThemeMode): String = stringResource(
|
|||||||
ThemeMode.DARK -> R.string.settings_theme_dark
|
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun launcherIconLabel(icon: LauncherIcon): String = stringResource(
|
||||||
|
when (icon) {
|
||||||
|
LauncherIcon.BLOOM -> R.string.settings_app_icon_bloom
|
||||||
|
LauncherIcon.PLAIN -> R.string.settings_app_icon_plain
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One selectable launcher-icon preview: the icon over its name, framed as a card. */
|
||||||
|
@Composable
|
||||||
|
private fun AppIconOptionCard(
|
||||||
|
icon: LauncherIcon,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (selected) scheme.primaryContainer.copy(alpha = 0.4f) else scheme.surfaceContainerHigh)
|
||||||
|
.border(
|
||||||
|
width = if (selected) 2.dp else 1.dp,
|
||||||
|
color = if (selected) scheme.primary else scheme.outlineVariant,
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// The adaptive icon rebuilt as a squircle, the way AppLogo draws it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(ICON_PREVIEW_SIZE)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(
|
||||||
|
when (icon) {
|
||||||
|
LauncherIcon.BLOOM -> R.drawable.ic_launcher_foreground
|
||||||
|
LauncherIcon.PLAIN -> R.drawable.ic_launcher_foreground_plain
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.requiredSize(ICON_PREVIEW_SIZE * ICON_PREVIEW_OVERSCAN),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = launcherIconLabel(icon),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = scheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier.background(scheme.primary)
|
||||||
|
} else {
|
||||||
|
Modifier.border(1.dp, scheme.outlineVariant, CircleShape)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = scheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ICON_PREVIEW_SIZE = 64.dp
|
||||||
|
|
||||||
|
/** As AppLogo's overscan: the foreground's safe margin would leave the mark small at 1:1. */
|
||||||
|
private const val ICON_PREVIEW_OVERSCAN = 1.5f
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ private fun AboutCard() {
|
|||||||
// own logo and author. Support is the [SupportRow] joined below.
|
// own logo and author. Support is the [SupportRow] joined below.
|
||||||
AboutCard(
|
AboutCard(
|
||||||
logo = { AppLogo() },
|
logo = { AppLogo() },
|
||||||
appName = stringResource(R.string.app_name),
|
appName = stringResource(R.string.app_brand_name),
|
||||||
author = stringResource(R.string.settings_about_author),
|
author = stringResource(R.string.settings_about_author),
|
||||||
primaryLinks = emptyList(),
|
primaryLinks = emptyList(),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
|
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.LauncherIconManager
|
||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
|
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
|
||||||
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
|
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
|
||||||
@@ -18,7 +20,9 @@ import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
|
|||||||
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockSource
|
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockSource
|
||||||
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerMode
|
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerMode
|
||||||
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerUiState
|
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerUiState
|
||||||
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -29,6 +33,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -48,6 +53,8 @@ class SettingsViewModel @Inject internal constructor(
|
|||||||
private val preview: RingtonePreviewer,
|
private val preview: RingtonePreviewer,
|
||||||
private val worldClocks: WorldClockSource,
|
private val worldClocks: WorldClockSource,
|
||||||
private val deviceState: DeviceStateReader,
|
private val deviceState: DeviceStateReader,
|
||||||
|
private val launcherIcons: LauncherIconManager,
|
||||||
|
@param:IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,11 +72,22 @@ class SettingsViewModel @Inject internal constructor(
|
|||||||
SettingsUiState.Loaded(appearance, defaults, problemCount)
|
SettingsUiState.Loaded(appearance, defaults, problemCount)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher icon, backed by the manifest aliases' component-enabled
|
||||||
|
* state rather than a preference. Seeded with the manifest default and
|
||||||
|
* corrected off the main thread — each read is a binder round-trip.
|
||||||
|
*/
|
||||||
|
private val _launcherIcon = MutableStateFlow(LauncherIcon.BLOOM)
|
||||||
|
internal val launcherIcon: StateFlow<LauncherIcon> = _launcherIcon.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val results = SelfCheck.evaluate(deviceState.read())
|
val results = SelfCheck.evaluate(deviceState.read())
|
||||||
selfCheckProblemCount.value = results.count { it.status == CheckStatus.PROBLEM }
|
selfCheckProblemCount.value = results.count { it.status == CheckStatus.PROBLEM }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherIcon.value = withContext(io) { launcherIcons.current() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _ringtonePicker = MutableStateFlow<Pair<DefaultRingtoneKind, RingtonePickerUiState>?>(null)
|
private val _ringtonePicker = MutableStateFlow<Pair<DefaultRingtoneKind, RingtonePickerUiState>?>(null)
|
||||||
@@ -96,6 +114,17 @@ class SettingsViewModel @Inject internal constructor(
|
|||||||
|
|
||||||
fun onDynamicColorChanged(enabled: Boolean) = viewModelScope.launch { settings.setDynamicColor(enabled) }
|
fun onDynamicColorChanged(enabled: Boolean) = viewModelScope.launch { settings.setDynamicColor(enabled) }
|
||||||
|
|
||||||
|
/** The card highlights at once, then settles on what the component state reports. */
|
||||||
|
internal fun onLauncherIconSelected(icon: LauncherIcon) {
|
||||||
|
_launcherIcon.value = icon
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherIcon.value = withContext(io) {
|
||||||
|
launcherIcons.set(icon)
|
||||||
|
launcherIcons.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onVibrateChanged(value: Boolean) = viewModelScope.launch { settings.setVibrate(value) }
|
fun onVibrateChanged(value: Boolean) = viewModelScope.launch { settings.setVibrate(value) }
|
||||||
|
|
||||||
fun onSnoozeMinutesChanged(value: Int) = viewModelScope.launch { settings.setSnoozeMinutes(value) }
|
fun onSnoozeMinutesChanged(value: Int) = viewModelScope.launch { settings.setSnoozeMinutes(value) }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The plain launcher icon foreground, offered next to the bloom mark in
|
||||||
|
Settings → Appearance → App icon (LauncherIconManager).
|
||||||
|
|
||||||
|
The bloom icon's dial and hands with the badge left off: the dial closes
|
||||||
|
into a full circle and is re-centred on the canvas, so the face sits in
|
||||||
|
the middle of the adaptive-icon safe zone rather than up-left of it. Same
|
||||||
|
off-white strokes over the same sage background, and it fills the
|
||||||
|
<monochrome> slot for themed-icon launchers too.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="512"
|
||||||
|
android:viewportHeight="512">
|
||||||
|
<group
|
||||||
|
android:pivotX="256"
|
||||||
|
android:pivotY="256"
|
||||||
|
android:scaleX="0.66"
|
||||||
|
android:scaleY="0.66">
|
||||||
|
<!-- Dial -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="20"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M366 256 A110 110 0 1 0 146 256 A110 110 0 1 0 366 256" />
|
||||||
|
<!-- Minute hand, straight up -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="20"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M256 256 L256 166" />
|
||||||
|
<!-- Hour hand -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="20"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M256 256 L316 256" />
|
||||||
|
</group>
|
||||||
|
</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_plain" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground_plain" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -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_plain" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground_plain" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clockula</string>
|
<!-- The launcher and system label: a plain, translatable "Clock". The
|
||||||
|
brand name below stays Clockula in every language. -->
|
||||||
|
<string name="app_name">Clock</string>
|
||||||
|
<string name="app_brand_name" translatable="false">Clockula</string>
|
||||||
<string name="app_tagline">A modern Material 3 Expressive clock app.\nComing into bloom.</string>
|
<string name="app_tagline">A modern Material 3 Expressive clock app.\nComing into bloom.</string>
|
||||||
|
|
||||||
<!-- Alarms (M3): the notification channels and the ring actions -->
|
<!-- Alarms (M3): the notification channels and the ring actions -->
|
||||||
@@ -272,7 +275,7 @@
|
|||||||
<string name="settings_group_app">App</string>
|
<string name="settings_group_app">App</string>
|
||||||
<string name="settings_group_about">About</string>
|
<string name="settings_group_about">About</string>
|
||||||
<string name="settings_section_appearance">Appearance</string>
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
<string name="settings_appearance_subtitle">Theme and colour</string>
|
<string name="settings_appearance_subtitle">Theme, colour and app icon</string>
|
||||||
<string name="settings_theme">Theme</string>
|
<string name="settings_theme">Theme</string>
|
||||||
<string name="settings_theme_system">Follow system</string>
|
<string name="settings_theme_system">Follow system</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
@@ -280,6 +283,10 @@
|
|||||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Needs Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Needs Android 12 or newer</string>
|
||||||
|
<string name="settings_app_icon">App icon</string>
|
||||||
|
<string name="settings_app_icon_hint">The icon your launcher shows. After switching, the icon may move to a new spot on your home screen.</string>
|
||||||
|
<string name="settings_app_icon_bloom">Bloom</string>
|
||||||
|
<string name="settings_app_icon_plain">Plain</string>
|
||||||
<string name="settings_language">Language</string>
|
<string name="settings_language">Language</string>
|
||||||
<string name="settings_language_auto">Follow system</string>
|
<string name="settings_language_auto">Follow system</string>
|
||||||
<string name="settings_section_alarms">Alarms</string>
|
<string name="settings_section_alarms">Alarms</string>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ManifestRulesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `exactly the launcher, the door, the four widget configurators and the six widget receivers are exported`() {
|
fun `exactly the launcher and its two icon aliases, the door, the four widget configurators and the six widget receivers are exported`() {
|
||||||
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
|
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
|
||||||
.findAll(text)
|
.findAll(text)
|
||||||
.map { it.value }
|
.map { it.value }
|
||||||
@@ -49,6 +49,8 @@ class ManifestRulesTest {
|
|||||||
// widget configurators answer only APPWIDGET_CONFIGURE` below.
|
// widget configurators answer only APPWIDGET_CONFIGURE` below.
|
||||||
assertThat(exported).containsExactly(
|
assertThat(exported).containsExactly(
|
||||||
".MainActivity",
|
".MainActivity",
|
||||||
|
".BloomIconAlias",
|
||||||
|
".PlainIconAlias",
|
||||||
".interop.AlarmClockActivity",
|
".interop.AlarmClockActivity",
|
||||||
".ui.widget.config.ClockWidgetConfigActivity",
|
".ui.widget.config.ClockWidgetConfigActivity",
|
||||||
".ui.widget.config.AnalogClockConfigActivity",
|
".ui.widget.config.AnalogClockConfigActivity",
|
||||||
@@ -99,6 +101,27 @@ class ManifestRulesTest {
|
|||||||
assertThat(activityBlock(".MainActivity")).doesNotContain("android:permission")
|
assertThat(activityBlock(".MainActivity")).doesNotContain("android:permission")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher entry moved onto one alias per icon (LauncherIconManager).
|
||||||
|
* Exactly one may ship enabled, or a fresh install shows two icons or none.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the launcher entry is two icon aliases, only the bloom enabled by default`() {
|
||||||
|
val aliases = Regex("<activity-alias\\b[\\s\\S]*?</activity-alias>").findAll(text)
|
||||||
|
.map { it.value }
|
||||||
|
.associateBy { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] }
|
||||||
|
|
||||||
|
assertThat(aliases.keys).containsExactly(".BloomIconAlias", ".PlainIconAlias")
|
||||||
|
for (block in aliases.values) {
|
||||||
|
assertThat(block).contains("android:targetActivity=\".MainActivity\"")
|
||||||
|
assertThat(block).contains("android.intent.category.LAUNCHER")
|
||||||
|
assertThat(block).doesNotContain("android:permission")
|
||||||
|
}
|
||||||
|
assertThat(aliases.getValue(".BloomIconAlias")).contains("android:enabled=\"true\"")
|
||||||
|
assertThat(aliases.getValue(".PlainIconAlias")).contains("android:enabled=\"false\"")
|
||||||
|
assertThat(activityBlock(".MainActivity")).doesNotContain("android.intent.category.LAUNCHER")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every action the contract answers is named in the manifest`() {
|
fun `every action the contract answers is named in the manifest`() {
|
||||||
val missing = AlarmClockContract.ACTIONS.filterNot { text.contains("android:name=\"$it\"") }
|
val missing = AlarmClockContract.ACTIONS.filterNot { text.contains("android:name=\"$it\"") }
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.data.appicon
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pure decision logic behind the app-icon picker. The `PackageManager`
|
||||||
|
* seam itself is exercised on-device; these pin the read interpretation and
|
||||||
|
* the write order.
|
||||||
|
*/
|
||||||
|
class LauncherIconManagerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an enabled plain alias reads as PLAIN`() {
|
||||||
|
assertThat(launcherIconFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
|
||||||
|
.isEqualTo(LauncherIcon.PLAIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a default or disabled plain alias reads as BLOOM`() {
|
||||||
|
assertThat(launcherIconFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
|
||||||
|
.isEqualTo(LauncherIcon.BLOOM)
|
||||||
|
assertThat(launcherIconFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
|
||||||
|
.isEqualTo(LauncherIcon.BLOOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the write plan enables the target before disabling the other`() {
|
||||||
|
assertThat(iconWritePlan(LauncherIcon.PLAIN)).containsExactly(
|
||||||
|
IconAliasChange(LauncherIcon.PLAIN, enabled = true),
|
||||||
|
IconAliasChange(LauncherIcon.BLOOM, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
assertThat(iconWritePlan(LauncherIcon.BLOOM)).containsExactly(
|
||||||
|
IconAliasChange(LauncherIcon.BLOOM, enabled = true),
|
||||||
|
IconAliasChange(LauncherIcon.PLAIN, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.testing
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.LauncherIconManager
|
||||||
|
|
||||||
|
/** The launcher icon as a plain field, starting at the manifest default. */
|
||||||
|
class FakeLauncherIconManager(var icon: LauncherIcon = LauncherIcon.BLOOM) : LauncherIconManager {
|
||||||
|
override fun current(): LauncherIcon = icon
|
||||||
|
|
||||||
|
override fun set(icon: LauncherIcon) {
|
||||||
|
this.icon = icon
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.settings
|
package de.jeanlucmakiola.clockula.ui.settings
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
|
||||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||||
import de.jeanlucmakiola.clockula.testing.FakeDeviceStateReader
|
import de.jeanlucmakiola.clockula.testing.FakeDeviceStateReader
|
||||||
|
import de.jeanlucmakiola.clockula.testing.FakeLauncherIconManager
|
||||||
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
|
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
|
||||||
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
|
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
|
||||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||||
@@ -34,7 +36,10 @@ class SettingsViewModelTest {
|
|||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
val main = MainDispatcherExtension()
|
val main = MainDispatcherExtension()
|
||||||
|
|
||||||
private fun TestScope.newViewModel(tempDir: Path): SettingsViewModel {
|
private fun TestScope.newViewModel(
|
||||||
|
tempDir: Path,
|
||||||
|
launcherIcons: FakeLauncherIconManager = FakeLauncherIconManager(),
|
||||||
|
): SettingsViewModel {
|
||||||
val harness = worldClockHarness(tempDir)
|
val harness = worldClockHarness(tempDir)
|
||||||
return SettingsViewModel(
|
return SettingsViewModel(
|
||||||
settings = harness.settings,
|
settings = harness.settings,
|
||||||
@@ -42,6 +47,8 @@ class SettingsViewModelTest {
|
|||||||
preview = FakeRingtonePreviewer(),
|
preview = FakeRingtonePreviewer(),
|
||||||
worldClocks = harness.source,
|
worldClocks = harness.source,
|
||||||
deviceState = FakeDeviceStateReader(),
|
deviceState = FakeDeviceStateReader(),
|
||||||
|
launcherIcons = launcherIcons,
|
||||||
|
io = main.dispatcher,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +72,21 @@ class SettingsViewModelTest {
|
|||||||
assertThat(loaded.defaults.snoozeMinutes).isEqualTo(10)
|
assertThat(loaded.defaults.snoozeMinutes).isEqualTo(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the launcher icon reads the component state and writes through`(@TempDir tempDir: Path) =
|
||||||
|
runTest(main.dispatcher) {
|
||||||
|
val icons = FakeLauncherIconManager(LauncherIcon.PLAIN)
|
||||||
|
val viewModel = newViewModel(tempDir, icons)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(viewModel.launcherIcon.value).isEqualTo(LauncherIcon.PLAIN)
|
||||||
|
|
||||||
|
viewModel.onLauncherIconSelected(LauncherIcon.BLOOM)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(icons.icon).isEqualTo(LauncherIcon.BLOOM)
|
||||||
|
assertThat(viewModel.launcherIcon.value).isEqualTo(LauncherIcon.BLOOM)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the theme mode writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
fun `the theme mode writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||||
val viewModel = collecting(newViewModel(tempDir))
|
val viewModel = collecting(newViewModel(tempDir))
|
||||||
|
|||||||
Reference in New Issue
Block a user