refactor(ui): Migrate from Fragments to Jetpack Compose

Replace Fragment-based UI with Jetpack Compose screens, adopting
Navigation3 for type-safe routing and Material3 theming throughout.

- Replace all Fragments/ViewHolders with Compose screens and cards
- Introduce NavigationController with Navigation3 runtime
- Add CapodTheme with Material3 design system
- Migrate PopUpWindow overlay to ComposeView with proper lifecycle
- Rewrite WidgetConfigurationActivity in Compose
- Add reusable settings composables (switch, slider, list preference)
- Update Kotlin to 2.2.10, add Compose BOM 2025.06.01
- Fix reactive state for unmatched devices toggle
- Add safety timeout for BT device enumeration
- Fix profile name validation error message
This commit is contained in:
darken
2026-02-24 15:05:09 +01:00
committed by Matthias Urhahn
parent 576c6db8d0
commit 63692595a8
166 changed files with 6328 additions and 7229 deletions
+7 -1
View File
@@ -5,9 +5,10 @@ plugins {
id("com.google.devtools.ksp")
id("kotlin-kapt")
id("kotlin-parcelize")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
apply(plugin = "dagger.hilt.android.plugin")
apply(plugin = "androidx.navigation.safeargs.kotlin")
android {
compileSdk = projectConfig.compileSdk
@@ -97,6 +98,7 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
compileOptions {
@@ -183,6 +185,10 @@ dependencies {
addNavigation()
addCompose()
addNavigation3()
addSerialization()
addTesting()
"gplayImplementation"("com.android.billingclient:billing:8.0.0")
@@ -0,0 +1,11 @@
package eu.darken.capod.common.compose
import androidx.compose.runtime.Composable
import eu.darken.capod.common.theming.CapodTheme
@Composable
fun PreviewWrapper(content: @Composable () -> Unit) {
CapodTheme {
content()
}
}
@@ -0,0 +1,13 @@
package eu.darken.capod.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import kotlinx.coroutines.flow.Flow
@Composable
fun <T> waitForState(flow: Flow<T>): State<T?> {
return produceState(initialValue = null) {
flow.collect { value = it }
}
}
@@ -0,0 +1,51 @@
package eu.darken.capod.common.error
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
@Composable
fun ErrorEventHandler(source: ErrorEventSource2) {
val errorEvents = source.errorEvents
var currentError by remember { mutableStateOf<Throwable?>(null) }
LaunchedEffect(errorEvents) { errorEvents.collect { error -> currentError = error } }
currentError?.let { error ->
ComposeErrorDialog(
throwable = error,
onDismiss = { currentError = null },
)
}
}
@Composable
private fun ComposeErrorDialog(
throwable: Throwable,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(android.R.string.dialog_alert_title)) },
text = {
Text(
text = throwable.localizedMessage
?: throwable.message
?: throwable::class.simpleName
?: "Unknown error"
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.ok))
}
},
)
}
@@ -0,0 +1,7 @@
package eu.darken.capod.common.error
import eu.darken.capod.common.flow.SingleEventFlow
interface ErrorEventSource2 {
val errorEvents: SingleEventFlow<Throwable>
}
@@ -0,0 +1,20 @@
package eu.darken.capod.common.flow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ChannelResult
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.AbstractFlow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.receiveAsFlow
class SingleEventFlow<T> : AbstractFlow<T>() {
private val channel = Channel<T>(Channel.Factory.BUFFERED)
override suspend fun collectSafely(collector: FlowCollector<T>) = channel.receiveAsFlow().collect(collector)
suspend fun emit(value: T) = channel.send(value)
fun tryEmit(value: T): ChannelResult<Unit> = channel.trySend(value)
fun emitBlocking(value: T): ChannelResult<Unit> = channel.trySendBlocking(value)
}
@@ -1,38 +0,0 @@
package eu.darken.capod.common.navigation
import android.app.Activity
import androidx.annotation.IdRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
fun Fragment.doNavigate(direction: NavDirections) = findNavController().doNavigate(direction)
fun Fragment.popBackStack(): Boolean {
if (!isAdded) {
IllegalStateException("Fragment is not added").also {
log(WARN) { "Trying to pop backstack on Fragment that isn't added to an Activity: ${it.asLog()}" }
}
return false
}
return findNavController().popBackStack()
}
/**
* [FragmentContainerView] does not access [NavController] in [Activity.onCreate]
* as workaround [FragmentManager] is used to get the [NavController]
* @param id [Int] NavFragment id
* @see <a href="https://issuetracker.google.com/issues/142847973">issue-142847973</a>
*/
@Throws(IllegalStateException::class)
fun FragmentManager.findNavController(@IdRes id: Int): NavController {
val fragment = findFragmentById(id) ?: throw IllegalStateException("Fragment is not found for id:$id")
return NavHostFragment.findNavController(fragment)
}
@@ -0,0 +1,5 @@
package eu.darken.capod.common.navigation
import androidx.compose.runtime.staticCompositionLocalOf
val LocalNavigationController = staticCompositionLocalOf<NavigationController?> { null }
@@ -0,0 +1,42 @@
package eu.darken.capod.common.navigation
import kotlinx.serialization.Serializable
object Nav {
sealed interface Main : NavigationDestination {
@Serializable
data object Overview : Main
@Serializable
data object Onboarding : Main
@Serializable
data object DeviceManager : Main
@Serializable
data class DeviceProfileCreation(val profileId: String? = null) : Main
@Serializable
data object TroubleShooter : Main
}
sealed interface Settings : NavigationDestination {
@Serializable
data object Index : Settings
@Serializable
data object General : Settings
@Serializable
data object Reactions : Settings
@Serializable
data object Debug : Settings
@Serializable
data object Support : Settings
@Serializable
data object Acknowledgements : Settings
}
}
@@ -1,22 +0,0 @@
package eu.darken.capod.common.navigation
import android.os.Bundle
import androidx.annotation.IdRes
import androidx.navigation.NavController
import androidx.navigation.NavDirections
fun NavController.navigateIfNotThere(@IdRes resId: Int, args: Bundle? = null) {
if (currentDestination?.id == resId) return
navigate(resId, args)
}
fun NavController.doNavigate(direction: NavDirections) {
currentDestination?.getAction(direction.actionId)?.let { navigate(direction) }
}
fun NavController.isGraphSet(): Boolean = try {
graph
true
} catch (e: IllegalStateException) {
false
}
@@ -1,9 +0,0 @@
package eu.darken.capod.common.navigation
import androidx.annotation.IdRes
import androidx.navigation.NavDestination
fun NavDestination?.hasAction(@IdRes id: Int): Boolean {
if (this == null) return false
return getAction(id) != null
}
@@ -0,0 +1,13 @@
package eu.darken.capod.common.navigation
sealed interface NavEvent {
data class GoTo(
val destination: NavigationDestination,
val popUpTo: NavigationDestination? = null,
val inclusive: Boolean = false,
) : NavEvent
data object Up : NavEvent
data object Finish : NavEvent
}
@@ -0,0 +1,66 @@
package eu.darken.capod.common.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavigationController @Inject constructor() {
private var _backStack: NavBackStack<NavKey>? = null
private val backStack: NavBackStack<NavKey>
get() = _backStack ?: error("NavigationController not initialized")
fun setup(backStack: NavBackStack<NavKey>) {
log(TAG) { "setup()" }
_backStack = backStack
}
fun up(): Boolean {
if (backStack.size <= 1) {
log(TAG) { "up() prevented removing the last element in backstack" }
return false
}
val removed = backStack.removeLastOrNull()
log(TAG) { "up() to ${backStack.lastOrNull()} (removed $removed)" }
return removed != null
}
fun goTo(
destination: NavigationDestination,
popUpTo: NavigationDestination? = null,
inclusive: Boolean = false
) {
log(TAG) { "goTo($destination, popUpTo=$popUpTo, inclusive=$inclusive)" }
if (popUpTo != null) {
if (backStack.none { it == popUpTo }) {
log(TAG) { "popUpTo $popUpTo not found in backstack, skipping pop" }
} else {
while (backStack.isNotEmpty() && backStack.last() != popUpTo) {
val removed = backStack.removeLastOrNull()
log(TAG) { "Popping $removed while looking for $popUpTo" }
}
if (inclusive && backStack.isNotEmpty() && backStack.last() == popUpTo) {
val removed = backStack.removeLastOrNull()
log(TAG) { "Popping $removed (inclusive)" }
}
}
}
backStack.add(destination)
}
fun replace(destination: NavigationDestination) {
backStack.removeLastOrNull()
backStack.add(destination)
}
companion object {
private val TAG = logTag("Navigation", "Controller")
}
}
@@ -0,0 +1,5 @@
package eu.darken.capod.common.navigation
import androidx.navigation3.runtime.NavKey
interface NavigationDestination : NavKey, java.io.Serializable
@@ -0,0 +1,8 @@
package eu.darken.capod.common.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
interface NavigationEntry {
fun EntryProviderScope<NavKey>.setup()
}
@@ -0,0 +1,38 @@
package eu.darken.capod.common.navigation
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@Composable
fun NavigationEventHandler(vararg sources: NavigationEventSource) {
val navController = LocalNavigationController.current ?: return
val context = LocalContext.current
val activity = context as? Activity
sources.forEach { source ->
val navEvents = source.navEvents
LaunchedEffect(navEvents) {
navEvents.collect { event ->
when (event) {
is NavEvent.GoTo -> navController.goTo(
destination = event.destination,
popUpTo = event.popUpTo,
inclusive = event.inclusive,
)
NavEvent.Up -> navController.up()
NavEvent.Finish -> {
log(TAG) { "Finish event received, closing activity" }
activity?.finish()
}
}
}
}
}
}
private val TAG = logTag("NavigationEventHandler")
@@ -0,0 +1,7 @@
package eu.darken.capod.common.navigation
import eu.darken.capod.common.flow.SingleEventFlow
interface NavigationEventSource {
val navEvents: SingleEventFlow<NavEvent>
}
@@ -1,43 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import android.widget.Toast
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.preference.Preference
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.R
class IntentPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle,
@StyleRes defStyleRes: Int = 0,
) : Preference(context, attrs, defStyleAttr, defStyleRes) {
override fun setIntent(_intent: Intent?) {
super.setIntent(_intent)
_intent?.let {
intent
setOnPreferenceClickListener {
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
log(TAG, ERROR) { "Failed to launch $intent: $e" }
Toast.makeText(context, e.toString(), Toast.LENGTH_LONG).show()
}
true
}
}
}
companion object {
private val TAG = logTag("IntentPreference")
}
}
@@ -1,15 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.Context
import android.util.AttributeSet
import androidx.preference.SwitchPreferenceCompat
import eu.darken.capod.R
class MaterialSwitchPreference(context: Context, attrs: AttributeSet?) :
SwitchPreferenceCompat(context, attrs) {
init {
// Use material switch
widgetLayoutResource = R.layout.preference_material_switch
}
}
@@ -1,94 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.Context
import android.content.res.TypedArray
import android.os.Parcelable
import android.util.AttributeSet
import androidx.annotation.PluralsRes
import androidx.preference.DialogPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import eu.darken.capod.R
import eu.darken.capod.common.preferences.PercentSliderPreferenceDialogFragment.Companion.newInstance
import kotlinx.parcelize.Parcelize
class PercentSliderPreference(context: Context, attrs: AttributeSet?) : DialogPreference(context, attrs) {
@get:PluralsRes val sliderTextPluralsResource: Int
val min: Float
val max: Float
private var internalValue = 0f
private var internalValueSet = false
init {
val a = getContext().obtainStyledAttributes(attrs, R.styleable.PercentSliderPreference)
min = a.getFloat(R.styleable.PercentSliderPreference_pspMin, 0f)
max = a.getFloat(R.styleable.PercentSliderPreference_pspMax, 1f)
sliderTextPluralsResource = a.getResourceId(R.styleable.PercentSliderPreference_sliderText, 0)
a.recycle()
}
// Always persist/notify the first time.
var value: Float
get() = internalValue
set(value) {
// Always persist/notify the first time.
val changed = internalValue != value
if (changed || !internalValueSet) {
internalValue = value
internalValueSet = true
persistFloat(value)
if (changed) notifyChanged()
}
}
override fun onGetDefaultValue(a: TypedArray, index: Int): Int {
return a.getInteger(index, 0)
}
override fun onSetInitialValue(restoreValue: Boolean, defaultValue: Any?) {
value = if (restoreValue) getPersistedFloat(internalValue) else defaultValue as Float
}
override fun onSaveInstanceState(): Parcelable? {
val superState = super.onSaveInstanceState()
// No need to save instance state since it's persistent
if (isPersistent) return superState
return SavedState(value = value, superState = superState)
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state?.javaClass != SavedState::class.java) {
// Didn't save state for us in onSaveInstanceState
return super.onRestoreInstanceState(state)
}
val myState = state as SavedState
super.onRestoreInstanceState(myState.superState)
value = myState.value
}
@Parcelize
data class SavedState(
val value: Float,
val superState: Parcelable?,
) : Parcelable
companion object {
private const val DIALOG_FRAGMENT_TAG = "android.support.v7.preference.PreferenceFragment.DIALOG"
fun onDisplayPreferenceDialog(preferenceFragment: PreferenceFragmentCompat, preference: Preference): Boolean {
if (preference is PercentSliderPreference) {
val fragmentManager = preferenceFragment.fragmentManager
if (fragmentManager!!.findFragmentByTag(DIALOG_FRAGMENT_TAG) == null) {
val dialogFragment = newInstance(preference.getKey())
dialogFragment.setTargetFragment(preferenceFragment, 0)
dialogFragment.show(fragmentManager, DIALOG_FRAGMENT_TAG)
}
return true
}
return false
}
}
}
@@ -1,92 +0,0 @@
package eu.darken.capod.common.preferences
import android.os.Bundle
import android.view.Gravity
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams
import android.widget.LinearLayout.VERTICAL
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceDialogFragmentCompat
import eu.darken.capod.common.UIConverter
import kotlin.math.roundToInt
class PercentSliderPreferenceDialogFragment : PreferenceDialogFragmentCompat(), SeekBar.OnSeekBarChangeListener {
private val layoutContainer by lazy { LinearLayout(requireContext()) }
private val valueText by lazy { TextView(requireContext()) }
private val splashText by lazy { TextView(requireContext()) }
private val seekBar by lazy { SeekBar(requireContext()) }
private val preferencePercent: PercentSliderPreference
get() = super.getPreference() as PercentSliderPreference
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
layoutContainer.apply {
orientation = VERTICAL
val px: Int = UIConverter.convertDpToPixels(requireContext(), 24f)
setPadding(px, 0, px, 0)
}
splashText.apply {
if (preferencePercent.dialogMessage != null) splashText.text = preferencePercent.dialogMessage
layoutContainer.addView(this)
}
valueText.apply {
gravity = Gravity.CENTER_HORIZONTAL
textSize = 32f
layoutContainer.addView(this, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
seekBar.apply {
setOnSeekBarChangeListener(this@PercentSliderPreferenceDialogFragment)
max = (preferencePercent.max * 100).roundToInt()
progress = (preferencePercent.value * 100).roundToInt()
layoutContainer.addView(this, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
updateValueText()
builder.setView(layoutContainer)
builder.setNegativeButton(null, null)
super.onPrepareDialogBuilder(builder)
}
override fun onDialogClosed(positiveResult: Boolean) {
if (!positiveResult) return
val value: Int = seekBar.progress
if (preferencePercent.callChangeListener(value)) {
preferencePercent.value = value / 100f
}
}
override fun onProgressChanged(seek: SeekBar, value: Int, fromTouch: Boolean) {
if (value < preferencePercent.min) {
seek.progress = (preferencePercent.min * 100).roundToInt()
}
updateValueText()
}
private fun updateValueText() {
val count: Int = seekBar.progress
valueText.text = if (preferencePercent.sliderTextPluralsResource != 0) {
resources.getQuantityString(preferencePercent.sliderTextPluralsResource, count, count)
} else {
"$count%"
}
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
companion object {
@JvmStatic fun newInstance(key: String): PercentSliderPreferenceDialogFragment {
val fragment = PercentSliderPreferenceDialogFragment()
val arguments = Bundle()
arguments.putString(ARG_KEY, key)
fragment.arguments = arguments
return fragment
}
}
}
@@ -0,0 +1,96 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SettingsBaseItem(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
iconPainter: Painter? = null,
iconTinted: Boolean = true,
iconSize: Dp = 24.dp,
subtitle: String? = null,
enabled: Boolean = true,
onLongClick: (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
enabled = enabled,
onClick = onClick,
onLongClick = onLongClick,
)
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
val contentAlpha = if (enabled) 1f else 0.5f
val hasIcon = icon != null || iconPainter != null
val tint = if (iconTinted) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f * contentAlpha)
} else {
Color.Unspecified
}
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(iconSize),
tint = tint,
)
} else if (iconPainter != null) {
Icon(
painter = iconPainter,
contentDescription = null,
modifier = Modifier.size(iconSize),
tint = tint,
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = if (hasIcon) 16.dp else 0.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha)
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f * contentAlpha),
modifier = Modifier.padding(top = 2.dp)
)
}
}
trailingContent?.invoke()
}
}
@@ -0,0 +1,23 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun SettingsCategoryHeader(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
@@ -0,0 +1,18 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsDivider(
modifier: Modifier = Modifier
) {
HorizontalDivider(
modifier = modifier.padding(start = 72.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)
)
}
@@ -0,0 +1,91 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
@Composable
fun <T> SettingsListPreferenceItem(
icon: ImageVector,
title: String,
entries: List<T>,
selectedEntry: T,
onEntrySelected: (T) -> Unit,
entryLabel: @Composable (T) -> String,
modifier: Modifier = Modifier,
subtitle: String? = null,
enabled: Boolean = true,
) {
var showDialog by remember { mutableStateOf(false) }
SettingsBaseItem(
icon = icon,
title = title,
subtitle = subtitle ?: entryLabel(selectedEntry),
onClick = { if (enabled) showDialog = true },
modifier = modifier,
enabled = enabled,
)
if (showDialog) {
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text(text = title) },
text = {
Column(Modifier.selectableGroup()) {
entries.forEach { entry ->
val isSelected = entry == selectedEntry
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = {
onEntrySelected(entry)
showDialog = false
},
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = isSelected,
onClick = null,
)
Text(
text = entryLabel(entry),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showDialog = false }) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
}
@@ -0,0 +1,41 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun SettingsPreferenceItem(
icon: ImageVector,
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
value: String? = null,
enabled: Boolean = true,
) {
val contentAlpha = if (enabled) 1f else 0.5f
SettingsBaseItem(
icon = icon,
title = title,
onClick = onClick,
modifier = modifier,
subtitle = subtitle,
enabled = enabled,
trailingContent = if (value != null) {
{
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f * contentAlpha),
modifier = Modifier.padding(start = 16.dp)
)
}
} else null
)
}
@@ -0,0 +1,57 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun SettingsSliderItem(
icon: ImageVector,
title: String,
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
enabled: Boolean = true,
valueLabel: ((Float) -> String)? = null,
) {
Column(modifier = modifier.fillMaxWidth()) {
SettingsBaseItem(
icon = icon,
title = title,
subtitle = subtitle,
onClick = {},
enabled = enabled,
trailingContent = if (valueLabel != null) {
{
Text(
text = valueLabel(value),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(start = 16.dp)
)
}
} else null,
)
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 56.dp)
.padding(bottom = 8.dp),
)
}
}
@@ -0,0 +1,36 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun SettingsSwitchItem(
icon: ImageVector,
title: String,
subtitle: String?,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
SettingsBaseItem(
icon = icon,
title = title,
onClick = { onCheckedChange(!checked) },
modifier = modifier,
subtitle = subtitle,
enabled = enabled,
trailingContent = {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
modifier = Modifier.padding(start = 16.dp)
)
}
)
}
@@ -0,0 +1,92 @@
package eu.darken.capod.common.theming
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF3F7AFF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFDAE2FF),
onPrimaryContainer = Color(0xFF00174B),
secondary = Color(0xFF715C00),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFE16C),
onSecondaryContainer = Color(0xFF231B00),
tertiary = Color(0xFF006D39),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFF5CFFA0),
onTertiaryContainer = Color(0xFF00210D),
error = Color(0xFFBA1B1B),
errorContainer = Color(0xFFFFDAD4),
onError = Color(0xFFFFFFFF),
onErrorContainer = Color(0xFF410001),
background = Color(0xFFFEFBFF),
onBackground = Color(0xFF1B1B1F),
surface = Color(0xFFFEFBFF),
onSurface = Color(0xFF1B1B1F),
surfaceVariant = Color(0xFFE2E2EC),
onSurfaceVariant = Color(0xFF44464E),
outline = Color(0xFF75767F),
inverseSurface = Color(0xFF303033),
inverseOnSurface = Color(0xFFF2F0F5),
inversePrimary = Color(0xFFB1C5FF),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF3F7AFF),
onPrimary = Color(0xFF002A78),
primaryContainer = Color(0xFF003EA7),
onPrimaryContainer = Color(0xFFDAE2FF),
secondary = Color(0xFFE9C426),
onSecondary = Color(0xFF3B2F00),
secondaryContainer = Color(0xFF564500),
onSecondaryContainer = Color(0xFFFFE16C),
tertiary = Color(0xFF33E286),
onTertiary = Color(0xFF00391B),
tertiaryContainer = Color(0xFF005229),
onTertiaryContainer = Color(0xFF5CFFA0),
error = Color(0xFFFFB4A9),
errorContainer = Color(0xFF930006),
onError = Color(0xFF680003),
onErrorContainer = Color(0xFFFFDAD4),
background = Color(0xFF1B1B1F),
onBackground = Color(0xFFE3E1E6),
surface = Color(0xFF1B1B1F),
onSurface = Color(0xFFE3E1E6),
surfaceVariant = Color(0xFF44464E),
onSurfaceVariant = Color(0xFFC6C6D0),
outline = Color(0xFF8F909A),
inverseSurface = Color(0xFFE3E1E6),
inverseOnSurface = Color(0xFF1B1B1F),
inversePrimary = Color(0xFF0054D9),
)
@Composable
fun CapodTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}
@@ -1,95 +0,0 @@
package eu.darken.capod.common.uix
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LiveData
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.error.asErrorDialogBuilder
import eu.darken.capod.common.navigation.doNavigate
import eu.darken.capod.common.navigation.popBackStack
import eu.darken.capod.common.observe2
abstract class BottomSheetDialogFragment2 : BottomSheetDialogFragment() {
abstract val ui: ViewBinding
abstract val vdc: ViewModel3
internal val tag: String =
logTag("Fragment", "${this.javaClass.simpleName}(${Integer.toHexString(hashCode())})")
override fun onAttach(context: Context) {
log(tag, VERBOSE) { "onAttach(context=$context)" }
super.onAttach(context)
}
override fun onCreate(savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" }
super.onCreate(savedInstanceState)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
log(tag, VERBOSE) {
"onCreateView(inflater=$inflater, container=$container, savedInstanceState=$savedInstanceState"
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onViewCreated(view=$view, savedInstanceState=$savedInstanceState)" }
super.onViewCreated(view, savedInstanceState)
vdc.navEvents.observe2(this, ui) { dir -> dir?.let { doNavigate(it) } ?: popBackStack() }
vdc.errorEvents.observe2(this, ui) { it.asErrorDialogBuilder(requireContext()).show() }
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" }
super.onActivityCreated(savedInstanceState)
}
override fun onResume() {
log(tag, VERBOSE) { "onResume()" }
super.onResume()
}
override fun onPause() {
log(tag, VERBOSE) { "onPause()" }
super.onPause()
}
override fun onDestroyView() {
log(tag, VERBOSE) { "onDestroyView()" }
super.onDestroyView()
}
override fun onDetach() {
log(tag, VERBOSE) { "onDetach()" }
super.onDetach()
}
override fun onDestroy() {
log(tag, VERBOSE) { "onDestroy()" }
super.onDestroy()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" }
super.onActivityResult(requestCode, resultCode, data)
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -1,80 +0,0 @@
package eu.darken.capod.common.uix
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
abstract class Fragment2(@LayoutRes val layoutRes: Int?) : Fragment(layoutRes ?: 0) {
constructor() : this(null)
internal val tag: String =
logTag("Fragment", "${this.javaClass.simpleName}(${Integer.toHexString(hashCode())})")
override fun onAttach(context: Context) {
log(tag, VERBOSE) { "onAttach(context=$context)" }
super.onAttach(context)
}
override fun onCreate(savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" }
super.onCreate(savedInstanceState)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
log(tag, VERBOSE) {
"onCreateView(inflater=$inflater, container=$container, savedInstanceState=$savedInstanceState"
}
return layoutRes?.let { inflater.inflate(it, container, false) }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onViewCreated(view=$view, savedInstanceState=$savedInstanceState)" }
super.onViewCreated(view, savedInstanceState)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" }
super.onActivityCreated(savedInstanceState)
}
override fun onResume() {
log(tag, VERBOSE) { "onResume()" }
super.onResume()
}
override fun onPause() {
log(tag, VERBOSE) { "onPause()" }
super.onPause()
}
override fun onDestroyView() {
log(tag, VERBOSE) { "onDestroyView()" }
super.onDestroyView()
}
override fun onDetach() {
log(tag, VERBOSE) { "onDetach()" }
super.onDetach()
}
override fun onDestroy() {
log(tag, VERBOSE) { "onDestroy()" }
super.onDestroy()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" }
super.onActivityResult(requestCode, resultCode, data)
}
}
@@ -1,53 +0,0 @@
package eu.darken.capod.common.uix
import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.lifecycle.LiveData
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.asErrorDialogBuilder
import eu.darken.capod.common.navigation.doNavigate
import eu.darken.capod.common.navigation.popBackStack
abstract class Fragment3(@LayoutRes layoutRes: Int?) : Fragment2(layoutRes) {
constructor() : this(null)
abstract val ui: ViewBinding?
abstract val vm: ViewModel3
var onErrorEvent: ((Throwable) -> Boolean)? = null
var onFinishEvent: (() -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
vm.navEvents.observe2(ui) {
log { "navEvents: $it" }
it?.run { doNavigate(this) } ?: onFinishEvent?.invoke() ?: popBackStack()
}
vm.errorEvents.observe2(ui) {
val showDialog = onErrorEvent?.invoke(it) ?: true
if (showDialog) it.asErrorDialogBuilder(requireContext()).show()
}
}
inline fun <T> LiveData<T>.observe2(
crossinline callback: (T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -1,70 +0,0 @@
package eu.darken.capod.common.uix
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.MenuRes
import androidx.annotation.XmlRes
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.main.ui.settings.SettingsFragment
abstract class PreferenceFragment2
: PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener {
abstract val settings: Settings
@get:XmlRes
abstract val preferenceFile: Int
val toolbar: Toolbar
get() = (parentFragment as SettingsFragment).toolbar
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar.menu.clear()
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.preferenceDataStore = settings.preferenceDataStore
settings.preferences.registerOnSharedPreferenceChangeListener(this)
refreshPreferenceScreen()
}
override fun onDestroy() {
settings.preferences.unregisterOnSharedPreferenceChangeListener(this)
super.onDestroy()
}
override fun getCallbackFragment(): Fragment? = parentFragment
fun refreshPreferenceScreen() {
if (preferenceScreen != null) preferenceScreen = null
addPreferencesFromResource(preferenceFile)
onPreferencesCreated()
}
open fun onPreferencesCreated() {
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
}
fun setupMenu(@MenuRes menuResId: Int, block: (MenuItem) -> Unit) {
toolbar.apply {
menu.clear()
inflateMenu(menuResId)
setOnMenuItemClickListener {
block(it)
true
}
}
}
}
@@ -1,35 +0,0 @@
package eu.darken.capod.common.uix
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LiveData
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.error.asErrorDialogBuilder
abstract class PreferenceFragment3 : PreferenceFragment2() {
abstract val vm: ViewModel3
var onErrorEvent: ((Throwable) -> Boolean)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
vm.errorEvents.observe2 {
val showDialog = onErrorEvent?.invoke(it) ?: true
if (showDialog) it.asErrorDialogBuilder(requireContext()).show()
}
}
inline fun <T> LiveData<T>.observe2(
crossinline callback: (T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -0,0 +1,47 @@
package eu.darken.capod.common.uix
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.ErrorEventSource2
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.navigation.NavEvent
import eu.darken.capod.common.navigation.NavigationDestination
import eu.darken.capod.common.navigation.NavigationEventSource
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
abstract class ViewModel4(
dispatcherProvider: DispatcherProvider,
) : ViewModel2(dispatcherProvider), NavigationEventSource, ErrorEventSource2 {
override val navEvents = SingleEventFlow<NavEvent>()
override val errorEvents = SingleEventFlow<Throwable>()
init {
launchErrorHandler = CoroutineExceptionHandler { _, ex ->
log(TAG) { "Error during launch: ${ex.asLog()}" }
errorEvents.emitBlocking(ex)
}
}
override fun <T> Flow<T>.launchInViewModel() = this
.setupCommonEventHandlers(TAG) { "launchInViewModel()" }
.launchIn(vmScope)
fun navTo(
destination: NavigationDestination,
popUpTo: NavigationDestination? = null,
inclusive: Boolean = false,
) {
log(TAG) { "navTo($destination)" }
navEvents.tryEmit(NavEvent.GoTo(destination, popUpTo, inclusive))
}
fun navUp() {
log(TAG) { "navUp()" }
navEvents.tryEmit(NavEvent.Up)
}
}
@@ -1,179 +0,0 @@
package eu.darken.capod.common.uix
/*
* Copyright 2018 The Android Open Source Project
*
* 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
*
* http://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.
*/
import androidx.activity.ComponentActivity
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelLazy
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.get
import kotlin.reflect.KClass
/**
* Returns an existing ViewModel or creates a new one in the scope (usually, a fragment or
* an activity), associated with this `ViewModelProvider`.
*
* @see ViewModelProvider.get(Class)
*/
//@MainThread
//inline fun <reified VM : ViewModel> ViewModelProvider.get() = get(VM::class.java)
/**
* An implementation of [Lazy] used by [androidx.fragment.app.Fragment.viewModels] and
* [androidx.activity.ComponentActivity.viewmodels].
*
* [storeProducer] is a lambda that will be called during initialization, [VM] will be created
* in the scope of returned [ViewModelStore].
*
* [factoryProducer] is a lambda that will be called during initialization,
* returned [ViewModelProvider.Factory] will be used for creation of [VM]
*/
class ViewModelLazyKeyed<VM : ViewModel>(
private val viewModelClass: KClass<VM>,
private val keyProducer: (() -> String)? = null,
private val storeProducer: () -> ViewModelStore,
private val factoryProducer: () -> ViewModelProvider.Factory
) : Lazy<VM> {
private var cached: VM? = null
override val value: VM
get() {
val viewModel = cached
return if (viewModel == null) {
val factory = factoryProducer()
val store = storeProducer()
val key = keyProducer?.invoke() ?: "androidx.lifecycle.ViewModelProvider.DefaultKey"
ViewModelProvider(store, factory).get(
key + ":" + viewModelClass.java.canonicalName,
viewModelClass.java
).also {
cached = it
}
} else {
viewModel
}
}
override fun isInitialized() = cached != null
}
/**
* Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]:
* ```
* class MyFragment : Fragment() {
* val viewmodel: NYViewModel by viewmodels()
* }
* ```
*
* Custom [ViewModelProvider.Factory] can be defined via [factoryProducer] parameter,
* factory returned by it will be used to create [ViewModel]:
* ```
* class MyFragment : Fragment() {
* val viewmodel: MYViewModel by viewmodels { myFactory }
* }
* ```
*
* Default scope may be overridden with parameter [ownerProducer]:
* ```
* class MyFragment : Fragment() {
* val viewmodel: MYViewModel by viewmodels ({requireParentFragment()})
* }
* ```
*
* This property can be accessed only after this Fragment is attached i.e., after
* [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
*/
@MainThread
inline fun <reified VM : ViewModel> Fragment.viewModelsKeyed(
noinline keyProducer: (() -> String)? = null,
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
) = createViewModelLazyKeyed(VM::class, keyProducer, { ownerProducer().viewModelStore }, factoryProducer)
/**
* Returns a property delegate to access parent activity's [ViewModel],
* if [factoryProducer] is specified then [ViewModelProvider.Factory]
* returned by it will be used to create [ViewModel] first time.
*
* ```
* class MyFragment : Fragment() {
* val viewmodel: MyViewModel by activityViewModels()
* }
* ```
*
* This property can be accessed only after this Fragment is attached i.e., after
* [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
*/
@MainThread
inline fun <reified VM : ViewModel> Fragment.activityViewModelsKeyed(
noinline keyProducer: (() -> String)? = null,
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
) = createViewModelLazyKeyed(VM::class, keyProducer, { requireActivity().viewModelStore }, factoryProducer)
/**
* Helper method for creation of [ViewModelLazy], that resolves `null` passed as [factoryProducer]
* to default factory.
*/
@MainThread
fun <VM : ViewModel> Fragment.createViewModelLazyKeyed(
viewModelClass: KClass<VM>,
keyProducer: (() -> String)? = null,
storeProducer: () -> ViewModelStore,
factoryProducer: (() -> ViewModelProvider.Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
val application = activity?.application ?: throw IllegalStateException(
"ViewModel can be accessed only when Fragment is attached"
)
ViewModelProvider.AndroidViewModelFactory.getInstance(application)
}
return ViewModelLazyKeyed(viewModelClass, keyProducer, storeProducer, factoryPromise)
}
/**
* Returns a [Lazy] delegate to access the ComponentActivity's ViewModel, if [factoryProducer]
* is specified then [ViewModelProvider.Factory] returned by it will be used
* to create [ViewModel] first time.
*
* ```
* class MyComponentActivity : ComponentActivity() {
* val viewmodel: MyViewModel by viewmodels()
* }
* ```
*
* This property can be accessed only after the Activity is attached to the Application,
* and access prior to that will result in IllegalArgumentException.
*/
@MainThread
inline fun <reified VM : ViewModel> ComponentActivity.viewModelsKeyed(
noinline keyProducer: (() -> String)? = null,
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
val application = application ?: throw IllegalArgumentException(
"ViewModel can be accessed only when Activity is attached"
)
ViewModelProvider.AndroidViewModelFactory.getInstance(application)
}
return ViewModelLazyKeyed(VM::class, keyProducer, { viewModelStore }, factoryPromise)
}
@@ -1,94 +0,0 @@
package eu.darken.capod.common.viewbinding
import android.os.Handler
import android.os.Looper
import android.view.View
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
inline fun <FragmentT : Fragment, reified BindingT : ViewBinding> FragmentT.viewBinding() =
this.viewBinding(
bindingProvider = {
val bindingMethod = BindingT::class.java.getMethod("bind", View::class.java)
bindingMethod(null, requireView()) as BindingT
},
lifecycleOwnerProvider = { viewLifecycleOwner }
)
@Suppress("unused")
fun <FragmentT : Fragment, BindingT : ViewBinding> FragmentT.viewBinding(
bindingProvider: FragmentT.() -> BindingT,
lifecycleOwnerProvider: FragmentT.() -> LifecycleOwner
) = ViewBindingProperty(bindingProvider, lifecycleOwnerProvider)
class ViewBindingProperty<ComponentT : LifecycleOwner, BindingT : ViewBinding>(
private val bindingProvider: (ComponentT) -> BindingT,
private val lifecycleOwnerProvider: ComponentT.() -> LifecycleOwner
) : ReadOnlyProperty<ComponentT, BindingT> {
private val uiHandler = Handler(Looper.getMainLooper())
private var localRef: ComponentT? = null
private var viewBinding: BindingT? = null
private val onDestroyObserver = object : DefaultLifecycleObserver {
// Called right before Fragment.onDestroyView
override fun onDestroy(owner: LifecycleOwner) {
localRef?.lifecycle?.removeObserver(this) ?: return
localRef = null
uiHandler.post {
log(VERBOSE) { "Resetting viewBinding" }
viewBinding = null
}
}
}
@MainThread
override fun getValue(thisRef: ComponentT, property: KProperty<*>): BindingT {
if (localRef == null && viewBinding != null) {
log(WARN) { "Fragment.onDestroyView() was called, but the handler didn't execute our delayed reset." }
/**
* There is a fragment racecondition if you navigate to another fragment and quickly popBackStack().
* Our uiHandler.post { } will not have executed for some reason.
* In that case we manually null the old viewBinding, to allow for clean recreation.
*/
viewBinding = null
}
/**
* When quickly navigating, a fragment may be created that was never visible to the user.
* It's possible that [Fragment.onDestroyView] is called, but [DefaultLifecycleObserver.onDestroy] is not.
* This means the ViewBinding will is not be set to `null` and it still holds the previous layout,
* instead of the new layout that the Fragment inflated when navigating back to it.
*/
(localRef as? Fragment)?.view?.let {
if (it != viewBinding?.root && localRef === thisRef) {
log(WARN) { "Different view for the same fragment, resetting old viewBinding." }
viewBinding = null
}
}
viewBinding?.let {
// Only accessible from within the same component
require(localRef === thisRef)
return@getValue it
}
val lifecycle = lifecycleOwnerProvider(thisRef).lifecycle
return bindingProvider(thisRef).also {
viewBinding = it
localRef = thisRef
lifecycle.addObserver(onDestroyObserver)
}
}
}
@@ -1,28 +1,64 @@
package eu.darken.capod.main.ui
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.CompositionLocalProvider
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.navigation.findNavController
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.navigation.LocalNavigationController
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationController
import eu.darken.capod.common.navigation.NavigationEntry
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.common.uix.Activity2
import eu.darken.capod.databinding.MainActivityBinding
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : Activity2() {
private val vm: MainActivityVM by viewModels()
private lateinit var ui: MainActivityBinding
private val navController by lazy { supportFragmentManager.findNavController(R.id.nav_host) }
@Inject lateinit var navCtrl: NavigationController
@Inject lateinit var navigationEntries: Set<@JvmSuppressWildcards NavigationEntry>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
enableEdgeToEdge()
ui = MainActivityBinding.inflate(layoutInflater)
setContentView(ui.root)
setContent {
val backStack = rememberNavBackStack(Nav.Main.Overview)
navCtrl.setup(backStack)
CapodTheme {
CompositionLocalProvider(LocalNavigationController provides navCtrl) {
NavDisplay(
backStack = backStack,
onBack = {
if (!navCtrl.up()) {
finish()
}
},
entryProvider = entryProvider {
navigationEntries.forEach { entry ->
entry.apply {
log(TAG) { "Set up navigation entry: $this" }
setup()
}
}
},
)
}
}
}
}
companion object {
private val TAG = logTag("MainActivity")
}
}
@@ -1,14 +0,0 @@
package eu.darken.capod.main.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.uix.ViewModel2
import javax.inject.Inject
@HiltViewModel
class MainActivityVM @Inject constructor(
handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
) : ViewModel2(dispatcherProvider = dispatcherProvider)
@@ -1,33 +0,0 @@
package eu.darken.capod.main.ui.onboarding
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.EdgeToEdgeHelper
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.uix.Fragment3
import eu.darken.capod.common.viewbinding.viewBinding
import eu.darken.capod.databinding.OnboardingFragmentBinding
import javax.inject.Inject
@AndroidEntryPoint
class OnboardingFragment : Fragment3(R.layout.onboarding_fragment) {
override val vm: OnboardingFragmentVM by viewModels()
override val ui: OnboardingFragmentBinding by viewBinding()
@Inject lateinit var webpageTool: WebpageTool
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
EdgeToEdgeHelper(requireActivity()).apply {
insetsPadding(ui.root, left = true, right = true, top = true, bottom = true)
}
ui.goPrivacyPolicy.setOnClickListener { webpageTool.open(PrivacyPolicy.URL) }
ui.continueAction.setOnClickListener { vm.finishOnboarding() }
super.onViewCreated(view, savedInstanceState)
}
}
@@ -1,26 +0,0 @@
package eu.darken.capod.main.ui.onboarding
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import javax.inject.Inject
@HiltViewModel
class OnboardingFragmentVM @Inject constructor(
@Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
fun finishOnboarding() = launch {
generalSettings.isOnboardingDone.value = true
OnboardingFragmentDirections.actionOnboardingFragmentToOverviewFragment().navigate()
}
companion object {
val TAG = logTag("Onboarding", "Fragment", "VM")
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.onboarding
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class OnboardingNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.Onboarding> { OnboardingScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: OnboardingNavigation): NavigationEntry
}
}
@@ -0,0 +1,123 @@
package eu.darken.capod.main.ui.onboarding
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
@Composable
fun OnboardingScreenHost(vm: OnboardingViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
OnboardingScreen(
onPrivacyPolicy = { vm.openPrivacyPolicy() },
onContinue = { vm.finishOnboarding() },
)
}
@Composable
fun OnboardingScreen(
onPrivacyPolicy: () -> Unit,
onContinue: () -> Unit,
) {
Scaffold { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 32.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
Image(
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = Modifier.size(96.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge,
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(R.string.onboarding_body1),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.onboarding_body2),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.onboarding_body3),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.onboarding_body4),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(32.dp))
FilledTonalButton(onClick = onPrivacyPolicy) {
Text(text = stringResource(R.string.settings_privacy_policy_label))
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
) {
Text(text = stringResource(R.string.general_continue_action))
}
Spacer(modifier = Modifier.height(64.dp))
}
}
}
@@ -0,0 +1,32 @@
package eu.darken.capod.main.ui.onboarding
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.main.core.GeneralSettings
import javax.inject.Inject
@HiltViewModel
class OnboardingViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
fun openPrivacyPolicy() {
webpageTool.open(PrivacyPolicy.URL)
}
fun finishOnboarding() = launch {
generalSettings.isOnboardingDone.value = true
navTo(Nav.Main.Overview, popUpTo = Nav.Main.Onboarding, inclusive = true)
}
companion object {
private val TAG = logTag("Onboarding", "VM")
}
}
@@ -1,51 +0,0 @@
package eu.darken.capod.main.ui.overview
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.AsyncDiffer
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.differ.HasAsyncDiffer
import eu.darken.capod.common.lists.differ.setupDiffer
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveVH
import eu.darken.capod.main.ui.overview.cards.NoProfilesVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
import javax.inject.Inject
class OverviewAdapter @Inject constructor() :
ModularAdapter<OverviewAdapter.BaseVH<OverviewAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<OverviewAdapter.Item> {
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
init {
modules.add(DataBinderMod(data))
modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is DualPodsCardVH.Item }) { DualPodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is SinglePodsCardVH.Item }) { SinglePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is NoProfilesVH.Item }) { NoProfilesVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is MonitoringActiveVH.Item }) { MonitoringActiveVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnmatchedDevicesCardVH.Item }) { UnmatchedDevicesCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
}
override fun getItemCount(): Int = data.size
abstract class BaseVH<D : Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : VH(layoutId, parent), BindableVH<D, B>
interface Item : DifferItem
}
@@ -1,180 +0,0 @@
package eu.darken.capod.main.ui.overview
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.text.SpannableStringBuilder
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.EdgeToEdgeHelper
import eu.darken.capod.common.colorString
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lists.differ.update
import eu.darken.capod.common.lists.setupDefaults
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.Fragment3
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.viewbinding.viewBinding
import eu.darken.capod.databinding.MainFragmentBinding
import javax.inject.Inject
@AndroidEntryPoint
class OverviewFragment : Fragment3(R.layout.main_fragment) {
override val vm: OverviewFragmentVM by viewModels()
override val ui: MainFragmentBinding by viewBinding()
@Inject
lateinit var adapter: OverviewAdapter
lateinit var permissionLauncher: ActivityResultLauncher<String>
var awaitingPermission = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
awaitingPermission = savedInstanceState?.getBoolean("awaitingPermission") ?: false
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
log { "Request for $id was granted=$granted" }
vm.onPermissionResult(granted)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
EdgeToEdgeHelper(requireActivity()).apply {
insetsPadding(ui.root, left = true, right = true)
insetsPadding(ui.toolbar, top = true)
insetsPadding(ui.list, bottom = false)
}
ui.apply {
list.setupDefaults(adapter, dividers = false)
}
ui.toolbar.apply {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_item_devices -> {
vm.goToDeviceManager()
true
}
R.id.menu_item_settings -> {
vm.goToSettings()
true
}
R.id.menu_item_donate -> {
vm.onUpgrade()
true
}
R.id.menu_item_upgrade -> {
vm.onUpgrade()
true
}
else -> false
}
}
}
vm.listItems.observe2(ui) { adapter.update(it) }
vm.workerAutolaunch.observe2 {
// While UI is active, subscribe to the autolaunch routine
}
vm.requestPermissionEvent.observe2(ui) {
when (it) {
Permission.IGNORE_BATTERY_OPTIMIZATION -> {
awaitingPermission = true
startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${requireContext().packageName}".toUri()
)
)
}
Permission.SYSTEM_ALERT_WINDOW -> {
awaitingPermission = true
startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${requireContext().packageName}".toUri()
)
)
}
else -> {
permissionLauncher.launch(it.permissionId)
}
}
}
vm.upgradeState.observe2(ui) { info ->
val gplay = toolbar.menu.findItem(R.id.menu_item_upgrade)
val donate = toolbar.menu.findItem(R.id.menu_item_donate)
gplay.isVisible = false
donate.isVisible = false
val baseTitle = when (info.type) {
UpgradeRepo.Type.GPLAY -> {
if (info.isPro) {
getString(R.string.app_name_pro)
} else {
gplay.isVisible = true
getString(R.string.app_name)
}
}
UpgradeRepo.Type.FOSS -> {
if (info.isPro) {
getString(R.string.app_name_foss)
} else {
donate.isVisible = true
getString(R.string.app_name)
}
}
}.split(" ".toRegex())
.dropLastWhile { it.isEmpty() }
.toTypedArray()
toolbar.title = if (baseTitle.size == 2) {
val builder = SpannableStringBuilder(baseTitle[0] + " ")
val color = when (info.type) {
UpgradeRepo.Type.FOSS -> R.color.brand_secondary
else -> R.color.brand_tertiary
}
builder.append(colorString(requireContext(), color, baseTitle[1]))
} else {
getString(R.string.app_name)
}
}
vm.launchUpgradeFlow.observe2 {
it(requireActivity())
}
super.onViewCreated(view, savedInstanceState)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("awaitingPermission", awaitingPermission)
super.onSaveInstanceState(outState)
}
override fun onResume() {
super.onResume()
if (awaitingPermission) {
awaitingPermission = false
log { "awaitingPermission=true" }
vm.onPermissionResult(true)
}
}
}
@@ -1,251 +0,0 @@
package eu.darken.capod.main.ui.overview
import android.app.Activity
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveVH
import eu.darken.capod.main.ui.overview.cards.NoProfilesVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class OverviewFragmentVM @Inject constructor(
@Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val monitorControl: MonitorControl,
private val podMonitor: PodMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
init {
if (!generalSettings.isOnboardingDone.value) {
OverviewFragmentDirections.actionOverviewFragmentToOnboardingFragment().navigate()
}
}
val upgradeState = upgradeRepo.upgradeInfo
.onEach {
if (!it.isPro && it.error != null) {
errorEvents.postValue(it.error)
}
}
.asLiveData2()
val launchUpgradeFlow = SingleLiveEvent<(Activity) -> Unit>()
private val updateTicker = channelFlow<Unit> {
while (isActive) {
trySend(Unit)
delay(3000)
}
}
val workerAutolaunch: LiveData<Unit> = permissionTool.missingPermissions
.onEach {
if (it.isNotEmpty()) {
log(TAG) { "Missing permissions: $it" }
return@onEach
}
val shouldStartMonitor = when (generalSettings.monitorMode.value) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> bluetoothManager.connectedDevices.first().isNotEmpty()
MonitorMode.ALWAYS -> true
}
if (shouldStartMonitor) {
log(TAG) { "Starting monitor" }
monitorControl.startMonitor()
}
}
.map { }
.asLiveData2()
val requestPermissionEvent = SingleLiveEvent<Permission>()
private var showUnmatchedDevices = false
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions ->
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
}
podMonitor.devices
}
.catch { errorEvents.postValue(it) }
.throttleLatest(1000)
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
updateTicker,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled,
profilesRepo.profiles,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles ->
val items = mutableListOf<OverviewAdapter.Item>()
permissions
.map { perm ->
PermissionCardVH.Item(
permission = perm,
onRequest = { requestPermissionEvent.postValue(it) },
)
}
.run { items.addAll(this) }
if (permissions.isEmpty()) {
if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item)
} else if (profiles.isEmpty()) {
items.add(
0, NoProfilesVH.Item(
onManageDevices = {
OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate()
}
))
}
}
if (permissions.isEmpty() && isBluetoothEnabled) {
val now = Instant.now()
// Split devices into profiled and unmatched
val profiledDevices = devices.filter { it.meta.profile != null }
val unmatchedDevices = devices.filter { it.meta.profile == null }
// Add profiled devices first
profiledDevices.map { device ->
when (device) {
is DualPodDevice -> DualPodsCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
}
}.run { items.addAll(this) }
if (profiles.isNotEmpty() && devices.isEmpty()) {
items.add(MonitoringActiveVH.Item)
}
// Add unmatched devices section if any exist
if (unmatchedDevices.isNotEmpty()) {
items.add(UnmatchedDevicesCardVH.Item(
count = unmatchedDevices.size,
isExpanded = showUnmatchedDevices,
onToggle = { toggleUnmatchedDevices() }
))
// Show unmatched devices if expanded
if (showUnmatchedDevices) {
unmatchedDevices.map { device ->
when (device) {
is DualPodDevice -> DualPodsCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
}
}.run { items.addAll(this) }
}
}
}
items
}
.catch { errorEvents.postValue(it) }
.asLiveData2()
fun onPermissionResult(granted: Boolean) {
if (granted) permissionTool.recheck()
}
fun goToSettings() = launch {
OverviewFragmentDirections.actionOverviewFragmentToSettingsFragment().navigate()
}
fun goToDeviceManager() = launch {
OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate()
}
fun onUpgrade() = launch {
val call: (Activity) -> Unit = {
upgradeRepo.launchBillingFlow(it)
}
launchUpgradeFlow.postValue(call)
}
private fun toggleUnmatchedDevices() {
showUnmatchedDevices = !showUnmatchedDevices
}
companion object {
private val TAG = logTag("Overview", "OverviewFragmentVM")
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.overview
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class OverviewNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.Overview> { OverviewScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: OverviewNavigation): NavigationEntry
}
}
@@ -0,0 +1,308 @@
package eu.darken.capod.main.ui.overview
import android.app.Activity
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import eu.darken.capod.R
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import java.time.Instant
@Composable
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val context = LocalContext.current
val activity = context as? Activity
// Collect workerAutolaunch passively to keep it active
LaunchedEffect(Unit) {
vm.workerAutolaunch.collect {}
}
// Permission handling
var awaitingPermission by rememberSaveable { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
vm.onPermissionResult(granted)
}
// When returning from settings-based permissions
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (awaitingPermission) {
awaitingPermission = false
vm.onPermissionResult(true)
}
}
// Handle permission request events
LaunchedEffect(Unit) {
vm.requestPermissionEvent.collect { permission ->
when (permission) {
Permission.IGNORE_BATTERY_OPTIMIZATION -> {
awaitingPermission = true
context.startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${context.packageName}".toUri()
)
)
}
Permission.SYSTEM_ALERT_WINDOW -> {
awaitingPermission = true
context.startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${context.packageName}".toUri()
)
)
}
else -> {
permissionLauncher.launch(permission.permissionId)
}
}
}
}
// Handle upgrade flow events
LaunchedEffect(Unit) {
vm.launchUpgradeFlow.collect { action ->
activity?.let { action(it) }
}
}
val stateHolder = waitForState(vm.state)
val state = stateHolder.value ?: return
OverviewScreen(
state = state,
onRequestPermission = { vm.requestPermission(it) },
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OverviewScreen(
state: OverviewViewModel.State,
onRequestPermission: (Permission) -> Unit,
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = {
ToolbarTitle(upgradeInfo = state.upgradeInfo)
},
actions = {
IconButton(onClick = onManageDevices) {
Icon(
painter = painterResource(R.drawable.ic_baseline_devices_other_24),
contentDescription = stringResource(R.string.settings_devices_label),
)
}
IconButton(onClick = onSettings) {
Icon(
painter = painterResource(R.drawable.ic_baseline_settings_24),
contentDescription = stringResource(R.string.settings_general_label),
)
}
// Upgrade/donate button based on type and pro status
val info = state.upgradeInfo
when {
info.type == UpgradeRepo.Type.GPLAY && !info.isPro -> {
IconButton(onClick = onUpgrade) {
Icon(
painter = painterResource(R.drawable.ic_baseline_stars_24),
contentDescription = stringResource(R.string.general_upgrade_action),
)
}
}
info.type == UpgradeRepo.Type.FOSS && !info.isPro -> {
IconButton(onClick = onUpgrade) {
Icon(
painter = painterResource(R.drawable.ic_baseline_heart_24),
contentDescription = stringResource(R.string.general_donate_action),
)
}
}
}
},
)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
// 1. Permission cards
items(
items = state.permissions.toList(),
key = { it.permissionId },
) { permission ->
PermissionCard(
permission = permission,
onRequest = onRequestPermission,
)
}
// 2. Bluetooth disabled card
if (!state.isBluetoothEnabled && state.permissions.isEmpty()) {
item(key = "bluetooth_disabled") {
BluetoothDisabledCard()
}
}
// 3. No profiles card
if (state.profiles.isEmpty() && state.permissions.isEmpty() && state.isBluetoothEnabled) {
item(key = "no_profiles") {
NoProfilesCard(onManageDevices = onManageDevices)
}
}
// 4. Profiled device cards
if (state.permissions.isEmpty() && state.isBluetoothEnabled) {
items(
items = state.profiledDevices,
key = { it.identifier.hashCode() },
) { device ->
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
}
// 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
item(key = "monitoring_active") {
MonitoringActiveCard()
}
}
// 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) {
item(key = "unmatched_header") {
UnmatchedDevicesCard(
count = state.unmatchedDevices.size,
isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched,
)
}
if (state.showUnmatchedDevices) {
items(
items = state.unmatchedDevices,
key = { "unmatched_${it.identifier.hashCode()}" },
) { device ->
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
}
}
}
}
}
}
}
@Composable
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
when (device) {
is DualPodDevice -> DualPodsCard(device = device, showDebug = showDebug, now = now)
is SinglePodDevice -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
}
}
@Composable
private fun ToolbarTitle(upgradeInfo: UpgradeRepo.Info) {
val appName = stringResource(R.string.app_name)
val proName = stringResource(R.string.app_name_pro)
val fossName = stringResource(R.string.app_name_foss)
val titleParts = when (upgradeInfo.type) {
UpgradeRepo.Type.GPLAY -> {
if (upgradeInfo.isPro) proName else appName
}
UpgradeRepo.Type.FOSS -> {
if (upgradeInfo.isPro) fossName else appName
}
}.split(" ").filter { it.isNotEmpty() }
if (titleParts.size == 2) {
val suffixColor = when (upgradeInfo.type) {
UpgradeRepo.Type.FOSS -> colorResource(R.color.brand_secondary)
else -> colorResource(R.color.brand_tertiary)
}
Text(
text = buildAnnotatedString {
append("${titleParts[0]} ")
withStyle(SpanStyle(color = suffixColor)) {
append(titleParts[1])
}
},
)
} else {
Text(text = appName)
}
}
@@ -0,0 +1,165 @@
package eu.darken.capod.main.ui.overview
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.shareLatest
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import java.time.Instant
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
@HiltViewModel
class OverviewViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val monitorControl: MonitorControl,
private val podMonitor: PodMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
) : ViewModel4(dispatcherProvider) {
init {
if (!generalSettings.isOnboardingDone.value) {
navTo(Nav.Main.Onboarding, popUpTo = Nav.Main.Overview, inclusive = true)
}
}
val requestPermissionEvent = SingleEventFlow<Permission>()
val launchUpgradeFlow = SingleEventFlow<(Activity) -> Unit>()
private val showUnmatchedDevices = MutableStateFlow(false)
val workerAutolaunch = permissionTool.missingPermissions
.onEach { permissions ->
if (permissions.isNotEmpty()) {
log(TAG) { "Missing permissions: $permissions" }
return@onEach
}
val shouldStart = when (generalSettings.monitorMode.value) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> {
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
devices?.isNotEmpty() == true
}
MonitorMode.ALWAYS -> true
}
if (shouldStart) {
log(TAG) { "Starting monitor" }
monitorControl.startMonitor()
}
}
.shareLatest(scope = vmScope)
private val updateTicker = channelFlow<Unit> {
while (isActive) {
trySend(Unit)
delay(3000)
}
}
private val pods = permissionTool.missingPermissions
.flatMapLatest { permissions ->
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
}
podMonitor.devices
}
.catch { errorEvents.emitBlocking(it) }
.throttleLatest(1000)
val state = combine(
updateTicker,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled,
profilesRepo.profiles,
upgradeRepo.upgradeInfo,
showUnmatchedDevices,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched ->
State(
now = Instant.now(),
permissions = permissions,
devices = devices,
isDebugMode = isDebugMode,
isBluetoothEnabled = isBluetoothEnabled,
profiles = profiles,
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
)
}.shareLatest(scope = vmScope)
data class State(
val now: Instant,
val permissions: Set<Permission>,
val devices: List<PodDevice>,
val isDebugMode: Boolean,
val isBluetoothEnabled: Boolean,
val profiles: List<DeviceProfile>,
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
) {
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
}
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
permissionTool.recheck()
}
fun goToSettings() {
navTo(Nav.Settings.Index)
}
fun goToDeviceManager() {
navTo(Nav.Main.DeviceManager)
}
fun onUpgrade() = launch {
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) }
}
fun toggleUnmatchedDevices() {
showUnmatchedDevices.value = !showUnmatchedDevices.value
}
fun requestPermission(permission: Permission) {
requestPermissionEvent.tryEmit(permission)
}
companion object {
private val TAG = logTag("Overview", "VM")
}
}
@@ -0,0 +1,40 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
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.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
@Composable
fun BluetoothDisabledCard() {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.overview_bluetooth_disabled_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_bluetooth_disabled_description),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@@ -1,33 +0,0 @@
package eu.darken.capod.main.ui.overview.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.databinding.OverviewBluetoothDisabledItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
class BluetoothDisabledVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<BluetoothDisabledVH.Item, OverviewBluetoothDisabledItemBinding>(
R.layout.overview_bluetooth_disabled_item,
parent
) {
override val viewBinding = lazy {
OverviewBluetoothDisabledItemBinding.bind(itemView)
}
override val onBindData: OverviewBluetoothDisabledItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
}
object Item : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
get() = { old, new -> if (new::class.isInstance(old)) new else null }
}
}
@@ -0,0 +1,321 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.HasPodStyle
import eu.darken.capod.pods.core.HasStateDetection
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import java.time.Instant
@Composable
fun DualPodsCard(
device: DualPodDevice,
showDebug: Boolean,
now: Instant,
) {
val context = LocalContext.current
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Header: device icon + name + type on left, signal in top-right
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Device name + key icon
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = device.meta.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
)
if (device is ApplePods && device.meta.isIRKMatch) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (device.payload.private != null) Icons.Default.Key else Icons.Outlined.Key,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val deviceLabel = buildString {
append(device.getLabel(context))
if (device is HasPodStyle && showDebug) {
append(" (${device.podStyle.getColor(context)})")
}
if (device is DualApplePods && showDebug) {
append(" [${device.primaryPod.name}]")
}
}
Text(
text = deviceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Signal quality + antenna icon
Text(
text = device.getSignalQuality(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.SettingsInputAntenna,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(4.dp))
// Last seen
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// First seen (only show if > 1 minute has passed)
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(12.dp))
// Left pod battery
BatteryRow(
label = "L",
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
)
Spacer(modifier = Modifier.height(8.dp))
// Right pod battery
BatteryRow(
label = "R",
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent,
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
)
// Case battery + lid state
if (device is HasCase) {
Spacer(modifier = Modifier.height(8.dp))
BatteryRow(
label = "C",
iconRes = device.caseIcon,
batteryPercent = device.batteryCasePercent,
isCharging = device.isCaseCharging,
isInEar = false,
showEarDetection = false,
isMicrophone = false,
showMicrophone = false,
)
// Case lid state (below case row, aligned with label text)
if (device is DualApplePods) {
val lidState = device.caseLidState
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 32.dp, top = 2.dp),
) {
Icon(
imageVector = Icons.Default.GridView,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = when (lidState) {
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
else -> ""
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// Connection state
if (device is HasStateDetection) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = device.state.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Debug info
if (showDebug) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "--- Debug ---",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = device.rawDataHex.joinToString("\n"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun BatteryRow(
label: String,
iconRes: Int,
batteryPercent: Float?,
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
isMicrophone: Boolean,
showMicrophone: Boolean,
) {
val context = LocalContext.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(iconRes),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$label: ${formatBatteryPercent(context, batteryPercent)}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(80.dp),
)
LinearProgressIndicator(
progress = { batteryPercent ?: 0f },
modifier = Modifier
.weight(1f)
.height(8.dp),
)
// Status icons — fixed width, always rendered, alpha toggles visibility so positions stay stable
Row(
modifier = Modifier.width(52.dp),
horizontalArrangement = Arrangement.End,
) {
Icon(
imageVector = Icons.Default.BatteryChargingFull,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.alpha(if (isCharging) 1f else 0f),
tint = MaterialTheme.colorScheme.primary,
)
Icon(
imageVector = Icons.Default.KeyboardVoice,
contentDescription = stringResource(R.string.pods_microphone_label),
modifier = Modifier
.size(16.dp)
.alpha(if (showMicrophone && isMicrophone) 1f else 0f),
tint = MaterialTheme.colorScheme.primary,
)
Icon(
imageVector = Icons.Default.Hearing,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.alpha(if (showEarDetection && isInEar) 1f else 0f),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@@ -0,0 +1,40 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
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.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
@Composable
fun MonitoringActiveCard() {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.overview_monitoring_active_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_monitoring_active_description),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@@ -1,33 +0,0 @@
package eu.darken.capod.main.ui.overview.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.databinding.OverviewMonitoringActiveItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
class MonitoringActiveVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<MonitoringActiveVH.Item, OverviewMonitoringActiveItemBinding>(
R.layout.overview_monitoring_active_item,
parent
) {
override val viewBinding = lazy {
OverviewMonitoringActiveItemBinding.bind(itemView)
}
override val onBindData: OverviewMonitoringActiveItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
// No actions needed for this card - it's purely informational
}
object Item : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
get() = { old, new -> if (new::class.isInstance(old)) new else null }
}
}
@@ -0,0 +1,58 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
@Composable
fun NoProfilesCard(onManageDevices: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.overview_nomaindevice_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_nomaindevice_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onManageDevices,
modifier = Modifier.align(Alignment.End),
) {
Icon(
painter = painterResource(R.drawable.ic_baseline_devices_other_24),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text(text = stringResource(R.string.general_manage_devices_action))
}
}
}
}
@@ -1,35 +0,0 @@
package eu.darken.capod.main.ui.overview.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.databinding.OverviewNoprofilesItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
class NoProfilesVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<NoProfilesVH.Item, OverviewNoprofilesItemBinding>(
R.layout.overview_noprofiles_item,
parent
) {
override val viewBinding = lazy {
OverviewNoprofilesItemBinding.bind(itemView)
}
override val onBindData: OverviewNoprofilesItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
manageDevicesAction.setOnClickListener { item.onManageDevices() }
}
data class Item(
val onManageDevices: () -> Unit,
) : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
get() = { old, new -> if (new::class.isInstance(old)) new else null }
}
}
@@ -0,0 +1,55 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.permissions.Permission
@Composable
fun PermissionCard(
permission: Permission,
onRequest: (Permission) -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(permission.labelRes),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(permission.descriptionRes),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { onRequest(permission) },
modifier = Modifier.align(Alignment.End),
) {
Text(text = stringResource(R.string.general_grant_permission_action))
}
}
}
}
@@ -1,55 +0,0 @@
package eu.darken.capod.main.ui.overview.cards
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.ViewGroup
import androidx.core.view.isGone
import eu.darken.capod.R
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.databinding.OverviewPermissionItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
class PermissionCardVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<PermissionCardVH.Item, OverviewPermissionItemBinding>(
R.layout.overview_permission_item,
parent
) {
override val viewBinding = lazy {
OverviewPermissionItemBinding.bind(itemView)
}
override val onBindData: OverviewPermissionItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
permissionLabel.setText(item.permission.labelRes)
permissionDescription.setText(item.permission.descriptionRes)
grantAction.setOnClickListener { item.onRequest(item.permission) }
privacyPolicy.apply {
movementMethod = LinkMovementMethod.getInstance()
val ppText = getString(R.string.settings_privacy_policy_label)
val ppLink = PrivacyPolicy.URL
text = Html.fromHtml("<html><a href=\"$ppLink\">$ppText</a></html>", 0)
val ppp = setOf(
Permission.ACCESS_FINE_LOCATION,
Permission.ACCESS_BACKGROUND_LOCATION,
Permission.BLUETOOTH_SCAN
)
isGone = !ppp.contains(item.permission)
}
}
data class Item(
val permission: Permission,
val onRequest: (Permission) -> Unit
) : OverviewAdapter.Item {
override val stableId: Long = permission.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
get() = { old, new -> if (new::class.isInstance(old)) new else null }
}
}
@@ -0,0 +1,175 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import java.time.Instant
@Composable
fun SinglePodsCard(
device: SinglePodDevice,
showDebug: Boolean,
now: Instant,
) {
val context = LocalContext.current
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Header: name + device icon on left, signal on right
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = device.meta.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Signal quality + antenna icon
Text(
text = device.getSignalQuality(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.SettingsInputAntenna,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(4.dp))
// Last seen
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// First seen (only show if > 1 minute has passed)
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(12.dp))
// Battery level
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = formatBatteryPercent(context, device.batteryHeadsetPercent),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(48.dp),
)
LinearProgressIndicator(
progress = { device.batteryHeadsetPercent ?: 0f },
modifier = Modifier
.weight(1f)
.height(8.dp),
)
Row(
modifier = Modifier.width(60.dp),
horizontalArrangement = Arrangement.End,
) {
if (device is HasChargeDetection && device.isHeadsetBeingCharged) {
Icon(
imageVector = Icons.Default.BatteryChargingFull,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
if (device is HasEarDetection && device.isBeingWorn) {
Icon(
imageVector = Icons.Default.Hearing,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
// Debug info
if (showDebug) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "--- Debug ---",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = device.rawDataHex.joinToString("\n"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,105 @@
package eu.darken.capod.main.ui.overview.cards
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
@Composable
fun UnknownPodDeviceCard(
device: PodDevice,
showDebug: Boolean,
now: Instant,
) {
val context = LocalContext.current
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Header with signal in top-right
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Text(
text = device.getSignalQuality(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.SettingsInputAntenna,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when (device) {
is ApplePods -> stringResource(R.string.pods_unknown_contact_dev)
else -> stringResource(R.string.pods_unknown_label)
},
style = MaterialTheme.typography.bodyMedium,
)
if (showDebug) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.pods_unknown_raw_data_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = device.rawDataHex.joinToString("\n"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,54 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
@Composable
fun UnmatchedDevicesCard(
count: Int,
isExpanded: Boolean,
onToggle: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = pluralStringResource(R.plurals.overview_unmatched_devices_count, count, count),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onToggle) {
Text(
text = if (isExpanded) {
stringResource(R.string.general_hide_action)
} else {
stringResource(R.string.general_show_action)
}
)
}
}
}
}
@@ -1,46 +0,0 @@
package eu.darken.capod.main.ui.overview.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.databinding.OverviewUnmatchedDevicesItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
class UnmatchedDevicesCardVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<UnmatchedDevicesCardVH.Item, OverviewUnmatchedDevicesItemBinding>(
R.layout.overview_unmatched_devices_item,
parent
) {
override val viewBinding = lazy {
OverviewUnmatchedDevicesItemBinding.bind(itemView)
}
override val onBindData: OverviewUnmatchedDevicesItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
unmatchedCount.text = getQuantityString(R.plurals.overview_unmatched_devices_count, item.count, item.count)
val toggleText = if (item.isExpanded) {
context.getString(R.string.general_hide_action)
} else {
context.getString(R.string.general_show_action)
}
toggleAction.text = toggleText
toggleAction.setOnClickListener { item.onToggle() }
}
data class Item(
val count: Int,
val isExpanded: Boolean,
val onToggle: () -> Unit,
) : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
get() = { old, new -> if (new::class.isInstance(old)) new else null }
}
}
@@ -1,195 +0,0 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsDualItemBinding
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.HasPodStyle
import eu.darken.capod.pods.core.HasStateDetection
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import java.time.Instant
class DualPodsCardVH(parent: ViewGroup) :
PodDeviceVH<DualPodsCardVH.Item, OverviewPodsDualItemBinding>(
R.layout.overview_pods_dual_item,
parent
) {
override val viewBinding = lazy { OverviewPodsDualItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
val device = item.device
name.text = device.meta.profile?.label ?: "?"
deviceType.apply {
val sb = StringBuilder(device.getLabel(context))
if (device is HasPodStyle && item.showDebug) {
val style = device.podStyle
sb.append(" (${style.getColor(context)})")
}
text = sb
if (device is DualApplePods && item.showDebug) {
append(" [${device.primaryPod.name}]")
}
}
deviceIcon.setImageResource(device.iconRes)
podLeftIcon.setImageResource(device.leftPodIcon)
podRightIcon.setImageResource(device.rightPodIcon)
lastSeen.text =
context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
firstSeen.text =
context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
reception.text = item.getReceptionText()
keyIcon.apply {
isVisible = device is ApplePods && device.meta.isIRKMatch
if (device !is ApplePods) return@apply
setImageResource(
when {
device.payload.private != null -> R.drawable.ic_key_24
else -> R.drawable.ic_key_outline_24
}
)
}
// Pods battery state
device.apply {
val leftPercent = batteryLeftPodPercent
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
val rightPercent = batteryRightPodPercent
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
}
// Pods charging state
device.apply {
if (this is HasChargeDetectionDual) {
podLeftChargingIcon.isInvisible = !isLeftPodCharging
podLeftChargingLabel.isInvisible = !isLeftPodCharging
podRightChargingIcon.isInvisible = !isRightPodCharging
podRightChargingLabel.isInvisible = !isRightPodCharging
} else {
podLeftChargingIcon.isGone = true
podLeftChargingLabel.isGone = true
podRightChargingIcon.isGone = true
podRightChargingLabel.isGone = true
}
}
// Microphone state
device.apply {
if (this is HasDualMicrophone) {
podLeftMicrophoneIcon.isInvisible = !isLeftPodMicrophone
podLeftMicrophoneLabel.isInvisible = !isLeftPodMicrophone
podRightMicrophoneIcon.isInvisible = !isRightPodMicrophone
podRightMicrophoneLabel.isInvisible = !isRightPodMicrophone
} else {
podLeftMicrophoneIcon.isGone = true
podLeftMicrophoneLabel.isGone = true
podRightMicrophoneIcon.isGone = true
podRightMicrophoneLabel.isGone = true
}
}
// Pods wear state
device.apply {
if (this is HasEarDetectionDual) {
podLeftWearIcon.isInvisible = !isLeftPodInEar
podLeftWearLabel.isInvisible = !isLeftPodInEar
podRightWearIcon.isInvisible = !isRightPodInEar
podRightWearLabel.isInvisible = !isRightPodInEar
} else {
podLeftWearIcon.isGone = true
podLeftWearLabel.isGone = true
podRightWearIcon.isGone = true
podRightWearLabel.isGone = true
}
}
// Case charge state
device.apply {
if (this is HasCase) {
podCaseIcon.setImageResource(caseIcon)
podCaseBatteryIcon.isGone = false
val casePercent = batteryCasePercent
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
podCaseChargingIcon.isInvisible = !isCaseCharging
podCaseChargingLabel.isInvisible = !isCaseCharging
} else {
podCaseBatteryIcon.isGone = true
podCaseBatteryLabel.isGone = true
podCaseChargingIcon.isGone = true
podCaseChargingLabel.isGone = true
}
}
// Case lid state
device.apply {
if (this is DualApplePods) {
podCaseLidLabel.text = when (caseLidState) {
LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
else -> context.getString(R.string.pods_case_unknown_state)
}
val hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
podCaseLidIcon.isInvisible = hideInfo
podCaseLidLabel.isInvisible = hideInfo
} else {
podCaseLidIcon.isGone = true
podCaseLidLabel.isGone = true
}
}
// Connection state
device.apply {
val sb = StringBuilder()
if (this is HasStateDetection) {
sb.append(state.getLabel(context))
}
if (item.showDebug) {
sb.append("\n\n").append("---Debug---")
sb.append("\n").append(rawDataHex)
}
status.text = sb
status.isGone = sb.isEmpty()
}
}
data class Item(
override val now: Instant,
override val device: DualPodDevice,
override val showDebug: Boolean,
) : PodDeviceVH.Item
}
@@ -1,38 +0,0 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.main.ui.overview.OverviewAdapter
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.getSignalQuality
import java.time.Instant
abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B> {
fun Item.getReceptionText(): String = device.getSignalQuality(context)
.let { if (showDebug) "$it ${device.seenCounter}" else it }
interface Item : OverviewAdapter.Item {
val now: Instant
val device: PodDevice
val showDebug: Boolean
override val stableId: Long get() = device.identifier.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
get() = { old, new ->
if (new::class.isInstance(old)) new else null
}
}
}
@@ -1,101 +0,0 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsSingleItemBinding
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import java.time.Instant
class SinglePodsCardVH(parent: ViewGroup) :
PodDeviceVH<SinglePodsCardVH.Item, OverviewPodsSingleItemBinding>(
R.layout.overview_pods_single_item,
parent
) {
override val viewBinding = lazy { OverviewPodsSingleItemBinding.bind(itemView) }
override val onBindData = binding(payload = true) { item: Item ->
val device = item.device
name.text = device.meta.profile?.label ?: "?"
deviceType.text = device.getLabel(context)
deviceIcon.setImageResource(device.iconRes)
lastSeen.text =
context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
firstSeen.text =
context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
reception.text = item.getReceptionText()
keyIcon.apply {
isVisible = device is ApplePods && device.meta.isIRKMatch
if (device !is ApplePods) return@apply
setImageResource(
when {
device.payload.private != null -> R.drawable.ic_key_24
else -> R.drawable.ic_key_outline_24
}
)
}
// Battery level
device.apply {
val headsetPercent = batteryHeadsetPercent
batteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
batteryLabel.text = formatBatteryPercent(context, headsetPercent)
}
// Charge state
device.apply {
if (this is HasChargeDetection) {
chargingIcon.isInvisible = !isHeadsetBeingCharged
chargingLabel.isInvisible = !isHeadsetBeingCharged
} else {
chargingIcon.isGone = true
chargingLabel.isGone = true
}
}
// Has ear detection
device.apply {
if (this is HasEarDetection) {
wearIcon.isInvisible = !isBeingWorn
wearLabel.isInvisible = !isBeingWorn
} else {
wearIcon.isGone = true
wearLabel.isGone = true
}
}
status.apply {
val sb = StringBuilder()
if (item.showDebug) {
sb.append("--- Debug ---")
sb.append("\n").append(device.rawDataHex)
}
text = sb
isGone = !item.showDebug
}
}
data class Item(
override val now: Instant,
override val device: SinglePodDevice,
override val showDebug: Boolean,
) : PodDeviceVH.Item
}
@@ -1,44 +0,0 @@
package eu.darken.capod.main.ui.overview.cards.pods
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
class UnknownPodDeviceCardVH(parent: ViewGroup) :
PodDeviceVH<UnknownPodDeviceCardVH.Item, OverviewPodsUnknownItemBinding>(
R.layout.overview_pods_unknown_item,
parent
) {
override val viewBinding = lazy {
OverviewPodsUnknownItemBinding.bind(itemView)
}
override val onBindData = binding(payload = true) { item ->
val device = item.device
name.apply {
text = device.getLabel(context)
}
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = item.getReceptionText()
details.text = when (item.device) {
is ApplePods -> getString(R.string.pods_unknown_contact_dev)
else -> getString(R.string.pods_unknown_label)
}
rawdata.text = device.rawDataHex.joinToString("\n")
}
data class Item(
override val now: Instant,
override val device: PodDevice,
override val showDebug: Boolean = false,
) : PodDeviceVH.Item
}
@@ -1,131 +0,0 @@
package eu.darken.capod.main.ui.settings
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.EdgeToEdgeHelper
import eu.darken.capod.common.uix.Fragment2
import eu.darken.capod.common.viewbinding.viewBinding
import eu.darken.capod.databinding.SettingsFragmentBinding
import kotlinx.parcelize.Parcelize
@AndroidEntryPoint
class SettingsFragment : Fragment2(R.layout.settings_fragment),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
private val vm: SettingsFragmentVM by viewModels()
private val ui: SettingsFragmentBinding by viewBinding()
val toolbar: Toolbar
get() = ui.toolbar
private val screens = ArrayList<Screen>()
@Parcelize
data class Screen(
val fragmentClass: String,
val screenTitle: String?
) : Parcelable
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
EdgeToEdgeHelper(requireActivity()).apply {
insetsPadding(ui.root, left = true, right = true)
insetsPadding(ui.toolbar, top = true)
insetsPadding(ui.contentFrame, bottom = true)
}
childFragmentManager.addOnBackStackChangedListener {
val backStackCnt = childFragmentManager.backStackEntryCount
val newScreenInfo = when {
backStackCnt < screens.size -> {
// We popped the backstack, restore the underlying screen infos
// If there are none left, we are at the index again
screens.removeLastOrNull()
screens.lastOrNull() ?: Screen(
fragmentClass = SettingsIndexFragment::class.qualifiedName!!,
screenTitle = getString(R.string.settings_label)
)
}
else -> {
// We added the current fragment to the stack, the new fragment's infos were already set, do nothing.
null
}
}
newScreenInfo?.let { setCurrentScreenInfo(it) }
}
if (savedInstanceState == null) {
childFragmentManager
.beginTransaction()
.replace(R.id.content_frame, SettingsIndexFragment())
.commit()
} else {
savedInstanceState.getParcelableArrayList<Screen>(BKEY_SCREEN_INFOS)?.let {
screens.addAll(it)
}
screens.lastOrNull()?.let { setCurrentScreenInfo(it) }
}
ui.toolbar.apply {
subtitle = BuildConfigWrap.VERSION_DESCRIPTION_TINY
setNavigationOnClickListener { requireActivity().onBackPressed() }
}
super.onViewCreated(view, savedInstanceState)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelableArrayList(BKEY_SCREEN_INFOS, screens)
}
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val screenInfo = Screen(
fragmentClass = pref.fragment!!,
screenTitle = pref.title?.toString()
)
val args = Bundle().apply {
putAll(pref.extras)
putString(BKEY_SCREEN_TITLE, screenInfo.screenTitle)
}
val fragment = childFragmentManager.fragmentFactory
.instantiate(this::class.java.classLoader!!, pref.fragment!!)
.apply {
arguments = args
setTargetFragment(caller, 0)
}
setCurrentScreenInfo(screenInfo)
screens.add(screenInfo)
childFragmentManager.beginTransaction().apply {
replace(R.id.content_frame, fragment)
addToBackStack(null)
}.commit()
return true
}
private fun setCurrentScreenInfo(info: Screen) {
ui.toolbar.apply {
title = info.screenTitle
}
}
companion object {
private const val BKEY_SCREEN_TITLE = "preferenceScreenTitle"
private const val BKEY_SCREEN_INFOS = "preferenceScreenInfos"
}
}
@@ -1,13 +0,0 @@
package eu.darken.capod.main.ui.settings
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.uix.ViewModel2
import javax.inject.Inject
@HiltViewModel
class SettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
) : ViewModel2(dispatcherProvider)
@@ -1,55 +0,0 @@
package eu.darken.capod.main.ui.settings
import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.MainDirections
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.common.uix.PreferenceFragment2
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import javax.inject.Inject
@AndroidEntryPoint
class SettingsIndexFragment : PreferenceFragment2() {
@Inject lateinit var generalSettings: GeneralSettings
override val settings: Settings
get() = generalSettings
override val preferenceFile: Int = R.xml.preferences_index
@Inject lateinit var webpageTool: WebpageTool
@Inject lateinit var upgradeRepo: UpgradeRepo
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setupMenu(R.menu.menu_settings_index) { item ->
when (item.itemId) {
R.id.menu_item_sponsor -> {
upgradeRepo.getSponsorUrl()?.let { webpageTool.open(it) }
}
}
}
toolbar.menu?.findItem(R.id.menu_item_sponsor)?.isVisible = !upgradeRepo.getSponsorUrl().isNullOrEmpty()
super.onViewCreated(view, savedInstanceState)
}
override fun onPreferencesCreated() {
findPreference<Preference>("core.changelog")!!.summary = BuildConfigWrap.VERSION_DESCRIPTION
findPreference<Preference>("core.privacy")!!.setOnPreferenceClickListener {
webpageTool.open(PrivacyPolicy.URL)
true
}
findPreference<Preference>("core.profile.manager")!!.setOnPreferenceClickListener {
findNavController().navigate(MainDirections.actionGlobalDeviceManagerFragment())
true
}
super.onPreferencesCreated()
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.settings
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class SettingsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.Index> { SettingsScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: SettingsNavigation): NavigationEntry
}
}
@@ -0,0 +1,167 @@
package eu.darken.capod.main.ui.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
@Composable
fun SettingsScreenHost(vm: SettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by waitForState(vm.state)
state?.let {
SettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onGeneralSettings = { vm.navTo(Nav.Settings.General) },
onDeviceManager = { vm.navTo(Nav.Main.DeviceManager) },
onReactions = { vm.navTo(Nav.Settings.Reactions) },
onSupport = { vm.navTo(Nav.Settings.Support) },
onChangelog = { vm.openUrl("https://github.com/d4rken-org/capod/releases/latest") },
onHelpTranslate = { vm.openUrl("https://crowdin.com/project/capod") },
onAcknowledgements = { vm.navTo(Nav.Settings.Acknowledgements) },
onPrivacyPolicy = { vm.openUrl(PrivacyPolicy.URL) },
onSponsor = { url -> vm.openUrl(url) },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
state: SettingsViewModel.State,
onNavigateUp: () -> Unit,
onGeneralSettings: () -> Unit,
onDeviceManager: () -> Unit,
onReactions: () -> Unit,
onSupport: () -> Unit,
onChangelog: () -> Unit,
onHelpTranslate: () -> Unit,
onAcknowledgements: () -> Unit,
onPrivacyPolicy: () -> Unit,
onSponsor: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(text = stringResource(R.string.settings_label))
},
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
actions = {
val sponsorUrl = state.sponsorUrl
if (sponsorUrl != null) {
IconButton(onClick = { onSponsor(sponsorUrl) }) {
Icon(
painter = painterResource(R.drawable.ic_heart),
contentDescription = "Sponsor development",
)
}
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsBaseItem(
title = stringResource(R.string.settings_general_label),
subtitle = stringResource(R.string.settings_general_description),
iconPainter = painterResource(R.drawable.ic_baseline_settings_24),
onClick = onGeneralSettings,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_devices_label),
subtitle = stringResource(R.string.settings_devices_description),
iconPainter = painterResource(R.drawable.ic_baseline_devices_other_24),
onClick = onDeviceManager,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_reaction_label),
subtitle = stringResource(R.string.settings_reaction_description),
iconPainter = painterResource(R.drawable.ic_baseline_widgets_24),
onClick = onReactions,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_support_label),
subtitle = stringResource(R.string.settings_support_description),
iconPainter = painterResource(R.drawable.ic_baseline_support_agent_24),
onClick = onSupport,
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label))
}
item {
SettingsBaseItem(
title = stringResource(R.string.changelog_label),
subtitle = BuildConfigWrap.VERSION_DESCRIPTION,
iconPainter = painterResource(R.drawable.ic_changelog_onsurface),
onClick = onChangelog,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.help_translate_label),
subtitle = stringResource(R.string.help_translate_description),
iconPainter = painterResource(R.drawable.ic_baseline_translate_24),
onClick = onHelpTranslate,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_acknowledgements_label),
subtitle = stringResource(R.string.general_thank_you_label),
iconPainter = painterResource(R.drawable.ic_heart),
onClick = onAcknowledgements,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_privacy_policy_label),
subtitle = stringResource(R.string.settings_privacy_policy_desc),
iconPainter = painterResource(R.drawable.ic_baseline_book_24),
onClick = onPrivacyPolicy,
)
}
}
}
}
@@ -0,0 +1,35 @@
package eu.darken.capod.main.ui.settings
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.shareLatest
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepo,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
data class State(
val sponsorUrl: String?,
)
val state = upgradeRepo.upgradeInfo
.map { State(sponsorUrl = upgradeRepo.getSponsorUrl()) }
.shareLatest(scope = vmScope)
fun openUrl(url: String) {
webpageTool.open(url)
}
companion object {
private val TAG = logTag("Settings", "VM")
}
}
@@ -1,22 +0,0 @@
package eu.darken.capod.main.ui.settings.acks
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.uix.PreferenceFragment2
import eu.darken.capod.main.core.GeneralSettings
import javax.inject.Inject
@Keep
@AndroidEntryPoint
class AcknowledgementsFragment : PreferenceFragment2() {
private val vm: AcknowledgementsFragmentVM by viewModels()
override val preferenceFile: Int = R.xml.preferences_acknowledgements
@Inject lateinit var debugSettings: GeneralSettings
override val settings: GeneralSettings by lazy { debugSettings }
}
@@ -1,17 +0,0 @@
package eu.darken.capod.main.ui.settings.acks
import androidx.lifecycle.SavedStateHandle
import dagger.assisted.AssistedInject
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
class AcknowledgementsFragmentVM @AssistedInject constructor(
private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider
) : ViewModel3(dispatcherProvider) {
companion object {
private val TAG = logTag("Settings", "Acknowledgements", "VM")
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.settings.acks
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class AcknowledgementsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.Acknowledgements> { AcknowledgementsScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: AcknowledgementsNavigation): NavigationEntry
}
}
@@ -0,0 +1,164 @@
package eu.darken.capod.main.ui.settings.acks
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
@Composable
fun AcknowledgementsScreenHost(vm: AcknowledgementsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
AcknowledgementsScreen(
onNavigateUp = { vm.navUp() },
onOpenUrl = { url -> vm.openUrl(url) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AcknowledgementsScreen(
onNavigateUp: () -> Unit,
onOpenUrl: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.settings_acknowledgements_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsCategoryHeader(text = stringResource(R.string.general_thank_you_label))
}
item {
SettingsBaseItem(
title = stringResource(R.string.translators_thanks_title),
subtitle = stringResource(R.string.translators_thanks_description),
onClick = { onOpenUrl("https://crowdin.com/project/capod/activity-stream") },
)
}
item {
SettingsBaseItem(
title = "Max Patchs",
subtitle = "Thanks for the lovely icons.",
onClick = { onOpenUrl("https://twitter.com/maxpatchs") },
)
}
item {
SettingsBaseItem(
title = "OpenPods project",
subtitle = "Pioneering AirPod support on Android",
onClick = { onOpenUrl("https://github.com/adolfintel/OpenPods") },
)
}
item {
SettingsBaseItem(
title = "MagicPods project",
subtitle = "Pioneering AirPod support on Windows",
onClick = { onOpenUrl("https://github.com/steam3d/MagicPods-Windows") },
)
}
item {
SettingsBaseItem(
title = "FuriousMAC",
subtitle = "Research on the continuity protocol",
onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") },
)
}
item {
SettingsBaseItem(
title = "crowdin.com",
subtitle = "For supporting translation of open-source projects",
onClick = { onOpenUrl("https://crowdin.com/") },
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label))
}
item {
SettingsBaseItem(
title = "Glide",
subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)",
onClick = { onOpenUrl("https://github.com/bumptech/glide") },
)
}
item {
SettingsBaseItem(
title = "Material Design Icons",
subtitle = "materialdesignicons.com (SIL Open Font License 1.1 / Attribution 4.0 International)",
onClick = { onOpenUrl("https://github.com/Templarian/MaterialDesign") },
)
}
item {
SettingsBaseItem(
title = "Zwicon Icon Set",
subtitle = "Creative Commons Attribution 4.0 International",
onClick = { onOpenUrl("https://iconduck.com/sets/zwicon-icon-set") },
)
}
item {
SettingsBaseItem(
title = "Kotlin",
subtitle = "The Kotlin Programming Language. (APACHE 2.0)",
onClick = { onOpenUrl("https://github.com/JetBrains/kotlin") },
)
}
item {
SettingsBaseItem(
title = "Dagger",
subtitle = "A fast dependency injector for Android and Java. (APACHE 2.0)",
onClick = { onOpenUrl("https://github.com/google/dagger") },
)
}
item {
SettingsBaseItem(
title = "Moshi",
subtitle = "A modern JSON library for Kotlin and Java. (APACHE 2.0)",
onClick = { onOpenUrl("https://github.com/square/moshi") },
)
}
item {
SettingsBaseItem(
title = "Android",
subtitle = "Android Open Source Project (APACHE 2.0)",
onClick = { onOpenUrl("https://source.android.com/source/licenses.html") },
)
}
item {
SettingsBaseItem(
title = "Android",
subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.",
onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") },
)
}
}
}
}
@@ -0,0 +1,23 @@
package eu.darken.capod.main.ui.settings.acks
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4
import javax.inject.Inject
@HiltViewModel
class AcknowledgementsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
fun openUrl(url: String) {
webpageTool.open(url)
}
companion object {
private val TAG = logTag("Settings", "Acknowledgements", "VM")
}
}
@@ -1,58 +0,0 @@
package eu.darken.capod.main.ui.settings.general
import android.os.Bundle
import android.view.View
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import androidx.preference.ListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.fromHex
import eu.darken.capod.common.preferences.PercentSliderPreference
import eu.darken.capod.common.toHex
import eu.darken.capod.common.uix.PreferenceFragment3
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.pods.core.PodDevice
import javax.inject.Inject
@Keep
@AndroidEntryPoint
class GeneralSettingsFragment : PreferenceFragment3() {
override val vm: GeneralSettingsFragmentVM by viewModels()
@Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var webpageTool: WebpageTool
override val settings: GeneralSettings
get() = generalSettings
override val preferenceFile: Int = R.xml.preferences_general
private val monitorModePref by lazy { findPreference<ListPreference>(generalSettings.monitorMode.key)!! }
private val scanModePref by lazy { findPreference<ListPreference>(generalSettings.scannerMode.key)!! }
override fun onPreferencesCreated() {
monitorModePref.apply {
entries = MonitorMode.entries.map { getString(it.labelRes) }.toTypedArray()
entryValues = MonitorMode.entries.map { settings.monitorMode.rawWriter(it) as String }.toTypedArray()
}
scanModePref.apply {
entries = ScannerMode.entries.map { getString(it.labelRes) }.toTypedArray()
entryValues = ScannerMode.entries.map { settings.scannerMode.rawWriter(it) as String }.toTypedArray()
}
super.onPreferencesCreated()
}
override fun onDisplayPreferenceDialog(preference: Preference) {
if (PercentSliderPreference.onDisplayPreferenceDialog(this, preference)) return
super.onDisplayPreferenceDialog(preference)
}
}
@@ -1,21 +0,0 @@
package eu.darken.capod.main.ui.settings.general
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import javax.inject.Inject
@HiltViewModel
class GeneralSettingsFragmentVM @Inject constructor(
@Suppress("unused") private val handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
) : ViewModel3(dispatcherProvider) {
companion object {
private val TAG = logTag("Settings", "General", "VM")
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.settings.general
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class GeneralSettingsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.General> { GeneralSettingsScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: GeneralSettingsNavigation): NavigationEntry
}
}
@@ -0,0 +1,286 @@
package eu.darken.capod.main.ui.settings.general
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.main.core.MonitorMode
@Composable
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by waitForState(vm.state)
state?.let {
GeneralSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
onDebugSettings = { vm.goToDebugSettings() },
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GeneralSettingsScreen(
state: GeneralSettingsViewModel.State,
onNavigateUp: () -> Unit,
onMonitorModeSelected: (MonitorMode) -> Unit,
onScannerModeSelected: (ScannerMode) -> Unit,
onShowConnectedNotificationChanged: (Boolean) -> Unit,
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
onDebugSettings: () -> Unit,
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
var showMonitorModeDialog by remember { mutableStateOf(false) }
var showScannerModeDialog by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.settings_general_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsBaseItem(
title = stringResource(R.string.settings_monitor_mode_label),
subtitle = stringResource(state.monitorMode.labelRes),
iconPainter = painterResource(R.drawable.ic_baseline_disabled_visible_24),
onClick = { showMonitorModeDialog = true },
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_scanner_mode_label),
subtitle = stringResource(state.scannerMode.labelRes),
iconPainter = painterResource(R.drawable.ic_baseline_settings_bluetooth_24),
onClick = { showScannerModeDialog = true },
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label))
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_monitor_connected_notification_label),
subtitle = stringResource(R.string.settings_monitor_connected_notification_description),
iconPainter = painterResource(R.drawable.ic_checkbox_blank_badge_24),
onClick = { onShowConnectedNotificationChanged(!state.showConnectedNotification) },
trailingContent = {
Switch(
checked = state.showConnectedNotification,
onCheckedChange = onShowConnectedNotificationChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_keep_notification_after_disconnect_label),
subtitle = stringResource(R.string.settings_keep_notification_after_disconnect_description),
iconPainter = painterResource(R.drawable.ic_message_24),
onClick = { onKeepNotificationAfterDisconnectChanged(!state.keepNotificationAfterDisconnect) },
enabled = state.showConnectedNotification,
trailingContent = {
Switch(
checked = state.keepNotificationAfterDisconnect,
onCheckedChange = onKeepNotificationAfterDisconnectChanged,
enabled = state.showConnectedNotification,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_label),
subtitle = stringResource(R.string.settings_debug_description),
iconPainter = painterResource(R.drawable.ic_baseline_bug_report_24),
onClick = onDebugSettings,
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_compat_offloaded_filtering_disabled_title),
subtitle = stringResource(R.string.settings_compat_offloaded_filtering_disabled_summary),
iconPainter = painterResource(R.drawable.ic_filter_cog_outline_24),
onClick = { onOffloadedFilteringDisabledChanged(!state.isOffloadedFilteringDisabled) },
trailingContent = {
Switch(
checked = state.isOffloadedFilteringDisabled,
onCheckedChange = onOffloadedFilteringDisabledChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_compat_offloaded_batching_disabled_title),
subtitle = stringResource(R.string.settings_compat_offloaded_batching_disabled_summary),
iconPainter = painterResource(R.drawable.ic_format_list_group_24),
onClick = { onOffloadedBatchingDisabledChanged(!state.isOffloadedBatchingDisabled) },
trailingContent = {
Switch(
checked = state.isOffloadedBatchingDisabled,
onCheckedChange = onOffloadedBatchingDisabledChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_compat_indirectcallback_title),
subtitle = stringResource(R.string.settings_compat_indirectcallback_summary),
iconPainter = painterResource(R.drawable.ic_strategy_24),
onClick = { onUseIndirectScanResultCallbackChanged(!state.useIndirectScanResultCallback) },
trailingContent = {
Switch(
checked = state.useIndirectScanResultCallback,
onCheckedChange = onUseIndirectScanResultCallbackChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
}
}
if (showMonitorModeDialog) {
ListPreferenceDialog(
title = stringResource(R.string.settings_monitor_mode_label),
entries = MonitorMode.entries,
selectedEntry = state.monitorMode,
onEntrySelected = {
onMonitorModeSelected(it)
showMonitorModeDialog = false
},
entryLabel = { stringResource(it.labelRes) },
onDismiss = { showMonitorModeDialog = false },
)
}
if (showScannerModeDialog) {
ListPreferenceDialog(
title = stringResource(R.string.settings_scanner_mode_label),
entries = ScannerMode.entries,
selectedEntry = state.scannerMode,
onEntrySelected = {
onScannerModeSelected(it)
showScannerModeDialog = false
},
entryLabel = { stringResource(it.labelRes) },
onDismiss = { showScannerModeDialog = false },
)
}
}
@Composable
private fun <T> ListPreferenceDialog(
title: String,
entries: List<T>,
selectedEntry: T,
onEntrySelected: (T) -> Unit,
entryLabel: @Composable (T) -> String,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title) },
text = {
Column(Modifier.selectableGroup()) {
entries.forEach { entry ->
val isSelected = entry == selectedEntry
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onEntrySelected(entry) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = isSelected,
onClick = null,
)
Text(
text = entryLabel(entry),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
@@ -0,0 +1,86 @@
package eu.darken.capod.main.ui.settings.general
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.shareLatest
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
@HiltViewModel
class GeneralSettingsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
) : ViewModel4(dispatcherProvider) {
data class State(
val monitorMode: MonitorMode,
val scannerMode: ScannerMode,
val showConnectedNotification: Boolean,
val keepNotificationAfterDisconnect: Boolean,
val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean,
)
val state = combine(
generalSettings.monitorMode.flow,
generalSettings.scannerMode.flow,
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) { values ->
State(
monitorMode = values[0] as MonitorMode,
scannerMode = values[1] as ScannerMode,
showConnectedNotification = values[2] as Boolean,
keepNotificationAfterDisconnect = values[3] as Boolean,
isOffloadedFilteringDisabled = values[4] as Boolean,
isOffloadedBatchingDisabled = values[5] as Boolean,
useIndirectScanResultCallback = values[6] as Boolean,
)
}.shareLatest(scope = vmScope)
fun setMonitorMode(mode: MonitorMode) {
generalSettings.monitorMode.value = mode
}
fun setScannerMode(mode: ScannerMode) {
generalSettings.scannerMode.value = mode
}
fun setShowConnectedNotification(enabled: Boolean) {
generalSettings.useExtraMonitorNotification.value = enabled
}
fun setKeepNotificationAfterDisconnect(enabled: Boolean) {
generalSettings.keepConnectedNotificationAfterDisconnect.value = enabled
}
fun setOffloadedFilteringDisabled(disabled: Boolean) {
generalSettings.isOffloadedFilteringDisabled.value = disabled
}
fun setOffloadedBatchingDisabled(disabled: Boolean) {
generalSettings.isOffloadedBatchingDisabled.value = disabled
}
fun setUseIndirectScanResultCallback(enabled: Boolean) {
generalSettings.useIndirectScanResultCallback.value = enabled
}
fun goToDebugSettings() {
navTo(Nav.Settings.Debug)
}
companion object {
private val TAG = logTag("Settings", "General", "VM")
}
}
@@ -1,24 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.uix.PreferenceFragment3
import javax.inject.Inject
@Keep
@AndroidEntryPoint
class DebugSettingsFragment : PreferenceFragment3() {
override val vm: DebugSettingsFragmentVM by viewModels()
@Inject lateinit var debugSettings: DebugSettings
override val settings: DebugSettings
get() = debugSettings
override val preferenceFile: Int = R.xml.preferences_debug
}
@@ -1,23 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import javax.inject.Inject
@HiltViewModel
class DebugSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
private val debugSettings: DebugSettings,
) : ViewModel3(dispatcherProvider) {
companion object {
private val TAG = logTag("Settings", "Debug", "VM")
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
}
}
@@ -0,0 +1,118 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
@Composable
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by waitForState(vm.state)
state?.let {
DebugSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugSettingsScreen(
state: DebugSettingsViewModel.State,
onNavigateUp: () -> Unit,
onDebugModeChanged: (Boolean) -> Unit,
onShowFakeDataChanged: (Boolean) -> Unit,
onShowUnfilteredChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.settings_debug_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_mode_label),
subtitle = stringResource(R.string.settings_debug_mode_description),
iconPainter = painterResource(R.drawable.ic_baseline_bug_report_24),
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
trailingContent = {
Switch(
checked = state.isDebugModeEnabled,
onCheckedChange = onDebugModeChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_fake_data_label),
subtitle = stringResource(R.string.settings_fake_data_description),
iconPainter = painterResource(R.drawable.ic_baseline_data_array_24),
onClick = { onShowFakeDataChanged(!state.showFakeData) },
trailingContent = {
Switch(
checked = state.showFakeData,
onCheckedChange = onShowFakeDataChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_blescanner_unfiltered_label),
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
iconPainter = painterResource(R.drawable.ic_baseline_devices_other_24),
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
trailingContent = {
Switch(
checked = state.showUnfiltered,
onCheckedChange = onShowUnfilteredChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
}
}
}
@@ -0,0 +1,51 @@
package eu.darken.capod.main.ui.settings.general.debug
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.shareLatest
import eu.darken.capod.common.uix.ViewModel4
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
@HiltViewModel
class DebugSettingsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val debugSettings: DebugSettings,
) : ViewModel4(dispatcherProvider) {
data class State(
val isDebugModeEnabled: Boolean,
val showFakeData: Boolean,
val showUnfiltered: Boolean,
)
val state = combine(
debugSettings.isDebugModeEnabled.flow,
debugSettings.showFakeData.flow,
debugSettings.showUnfiltered.flow,
) { debugMode, fakeData, unfiltered ->
State(
isDebugModeEnabled = debugMode,
showFakeData = fakeData,
showUnfiltered = unfiltered,
)
}.shareLatest(scope = vmScope)
fun setDebugModeEnabled(enabled: Boolean) {
debugSettings.isDebugModeEnabled.value = enabled
}
fun setShowFakeData(enabled: Boolean) {
debugSettings.showFakeData.value = enabled
}
fun setShowUnfiltered(enabled: Boolean) {
debugSettings.showUnfiltered.value = enabled
}
companion object {
private val TAG = logTag("Settings", "Debug", "VM")
}
}
@@ -1,70 +0,0 @@
package eu.darken.capod.main.ui.settings.support
import android.os.Bundle
import android.view.View
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import androidx.navigation.findNavController
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.debug.recording.ui.RecorderConsentDialog
import eu.darken.capod.common.observe2
import eu.darken.capod.common.uix.PreferenceFragment3
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.ui.settings.SettingsFragmentDirections
import javax.inject.Inject
@Keep
@AndroidEntryPoint
class SupportFragment : PreferenceFragment3() {
override val vm: SupportFragmentVM by viewModels()
override val preferenceFile: Int = R.xml.preferences_support
@Inject lateinit var generalSettings: GeneralSettings
override val settings: GeneralSettings by lazy { generalSettings }
@Inject lateinit var webpageTool: WebpageTool
private val debugLogPref by lazy { findPreference<Preference>("support.debuglog")!! }
private val troubleshooterPref by lazy { findPreference<Preference>("support.troubleshooter")!! }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
vm.recorderState.observe2(this) { state ->
debugLogPref.setIcon(
if (state.isRecording) R.drawable.ic_cancel
else R.drawable.ic_baseline_bug_report_24
)
debugLogPref.setTitle(
if (state.isRecording) R.string.debug_debuglog_stop_action
else R.string.debug_debuglog_record_action
)
debugLogPref.summary = when {
state.isRecording -> state.currentLogPath?.path
else -> getString(R.string.debug_debuglog_record_action)
}
debugLogPref.setOnPreferenceClickListener {
if (state.isRecording) {
vm.stopDebugLog()
} else {
RecorderConsentDialog(requireContext(), webpageTool).showDialog {
vm.startDebugLog()
}
}
true
}
}
troubleshooterPref.setOnPreferenceClickListener {
val navController = requireActivity().findNavController(R.id.nav_host)
navController.navigate(SettingsFragmentDirections.actionSettingsFragmentToTroubleShooterFragment())
true
}
super.onViewCreated(view, savedInstanceState)
}
}
@@ -1,27 +0,0 @@
package eu.darken.capod.main.ui.settings.support
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.uix.ViewModel3
import javax.inject.Inject
@HiltViewModel
class SupportFragmentVM @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
private val recorderModule: RecorderModule,
) : ViewModel3(dispatcherProvider) {
val recorderState = recorderModule.state.asLiveData2()
fun startDebugLog() = launch {
log(TAG) { "startDebugLog()" }
recorderModule.startRecorder()
}
fun stopDebugLog() = launch {
log(TAG) { "stopDebugLog()" }
recorderModule.stopRecorder()
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.main.ui.settings.support
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class SupportNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.Support> { SupportScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: SupportNavigation): NavigationEntry
}
}
@@ -0,0 +1,124 @@
package eu.darken.capod.main.ui.settings.support
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
@Composable
fun SupportScreenHost(vm: SupportViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by waitForState(vm.state)
state?.let {
SupportScreen(
state = it,
onNavigateUp = { vm.navUp() },
onDiscord = { vm.openUrl("https://discord.gg/rrxxng35jq") },
onIssueTracker = { vm.openUrl("https://github.com/d4rken-org/capod/issues") },
onTroubleShooter = { vm.goToTroubleShooter() },
onDebugLogToggle = {
if (it.isRecording) vm.stopDebugLog()
else vm.startDebugLog()
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SupportScreen(
state: SupportViewModel.State,
onNavigateUp: () -> Unit,
onDiscord: () -> Unit,
onIssueTracker: () -> Unit,
onTroubleShooter: () -> Unit,
onDebugLogToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.settings_support_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsBaseItem(
title = stringResource(R.string.discord_label),
subtitle = stringResource(R.string.discord_description),
iconPainter = painterResource(R.drawable.ic_discord_onsurface),
onClick = onDiscord,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.issue_tracker_label),
subtitle = stringResource(R.string.issue_tracker_description),
iconPainter = painterResource(R.drawable.ic_github_onsurface),
onClick = onIssueTracker,
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label))
}
item {
SettingsBaseItem(
title = stringResource(R.string.troubleshooter_title),
subtitle = stringResource(R.string.troubleshooter_summary),
iconPainter = painterResource(R.drawable.ic_baseline_settings_24),
onClick = onTroubleShooter,
)
}
item {
SettingsBaseItem(
title = if (state.isRecording) {
stringResource(R.string.debug_debuglog_stop_action)
} else {
stringResource(R.string.debug_debuglog_record_action)
},
subtitle = if (state.isRecording) {
state.currentLogPath?.path
} else {
stringResource(R.string.debug_debuglog_record_action)
},
iconPainter = if (state.isRecording) {
painterResource(R.drawable.ic_cancel)
} else {
painterResource(R.drawable.ic_baseline_bug_report_24)
},
onClick = onDebugLogToggle,
)
}
}
}
}
@@ -0,0 +1,58 @@
package eu.darken.capod.main.ui.settings.support
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.flow.shareLatest
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import kotlinx.coroutines.flow.map
import java.io.File
import javax.inject.Inject
@HiltViewModel
class SupportViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val webpageTool: WebpageTool,
private val recorderModule: RecorderModule,
) : ViewModel4(dispatcherProvider) {
data class State(
val isRecording: Boolean,
val currentLogPath: File?,
)
val state = recorderModule.state
.map {
State(
isRecording = it.isRecording,
currentLogPath = it.currentLogPath,
)
}
.shareLatest(scope = vmScope)
fun openUrl(url: String) {
webpageTool.open(url)
}
fun goToTroubleShooter() {
navTo(Nav.Main.TroubleShooter)
}
fun startDebugLog() = launch {
log(TAG) { "startDebugLog()" }
recorderModule.startRecorder()
}
fun stopDebugLog() = launch {
log(TAG) { "stopDebugLog()" }
recorderModule.stopRecorder()
}
companion object {
private val TAG = logTag("Settings", "Support", "VM")
}
}
@@ -3,58 +3,31 @@ package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.text.Editable
import android.text.InputFilter
import android.text.TextWatcher
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.core.graphics.toColorInt
import androidx.core.view.isVisible
import com.google.android.material.chip.Chip
import androidx.compose.runtime.getValue
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.EdgeToEdgeHelper
import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.common.uix.Activity2
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.databinding.WidgetConfigurationActivityBinding
import javax.inject.Inject
@AndroidEntryPoint
class WidgetConfigurationActivity : Activity2() {
private val vm: WidgetConfigurationViewModel by viewModels()
private lateinit var ui: WidgetConfigurationActivityBinding
@Inject lateinit var profileAdapter: WidgetProfileSelectionAdapter
@Inject lateinit var upgradeRepo: UpgradeRepo
@ApplicationContext @Inject lateinit var appContext: Context
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
private var isUpdatingHexFromCode = false
private val checkerboardDrawable: BitmapDrawable by lazy { createCheckerboardDrawable() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -74,365 +47,34 @@ class WidgetConfigurationActivity : Activity2() {
return
}
ui = WidgetConfigurationActivityBinding.inflate(layoutInflater)
setContentView(ui.root)
EdgeToEdgeHelper(this).apply {
insetsPadding(ui.root, top = true, bottom = true, left = true, right = true)
}
ui.profilesRecycler.adapter = profileAdapter
setupPreviewContainer()
setupPresetChips()
setupColorSwatches(ui.bgColorGrid, isBg = true)
setupColorSwatches(ui.fgColorGrid, isBg = false)
setupHexInput()
setupTransparencySlider()
setupShowDeviceLabelSwitch()
setupResetButton()
ui.cancelButton.setOnClickListener {
log(TAG) { "Cancel clicked" }
finish()
}
ui.confirmButton.setOnClickListener {
if (vm.state.value?.isPro == true) {
log(TAG) { "Confirm clicked" }
confirmSelection()
} else {
log(TAG) { "Upgrade clicked" }
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity)
}
}
vm.state.observe2 { state ->
val adapterItems = state.profiles.map { profile ->
WidgetProfileSelectionVH.Item(
profile = profile,
isSelected = profile.id == state.selectedProfile,
onProfileClick = { vm.selectProfile(it.id) }
)
}
profileAdapter.asyncDiffer.submitUpdate(adapterItems)
ui.proRequiredCaption.isVisible = !state.isPro
if (state.isPro) {
ui.confirmButton.text = getString(android.R.string.ok)
ui.confirmButton.isEnabled = state.canConfirm
} else {
ui.confirmButton.text = getString(R.string.general_upgrade_action)
ui.confirmButton.isEnabled = true
}
updatePresetChipSelection(state.activePreset)
updateCustomSectionsVisibility(state.isCustomMode)
updateSwatchSelection(ui.bgColorGrid, state.theme.backgroundColor)
updateSwatchSelection(ui.fgColorGrid, state.theme.foregroundColor)
updateHexInputs(state.theme)
// Disable transparency slider when using Material You (no custom bg to apply alpha to)
val hasCustomBg = state.theme.backgroundColor != null
ui.transparencySlider.isEnabled = hasCustomBg
ui.transparencyLabel.alpha = if (hasCustomBg) 1.0f else 0.5f
val transparencyPercent = ((255 - state.theme.backgroundAlpha) / 255f * 100f)
val displayPercent = (transparencyPercent / 5f).toInt() * 5
if (!ui.transparencySlider.isPressed) {
ui.transparencySlider.value = displayPercent.toFloat()
}
ui.transparencyLabel.text = getString(
R.string.widget_config_transparency_label
) + if (hasCustomBg && displayPercent > 0) " ($displayPercent%)" else ""
ui.showDeviceLabelSwitch.isChecked = state.theme.showDeviceLabel
val selectedProfileLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label
val deviceLabel = ui.previewCard.findViewById<android.widget.TextView>(R.id.preview_device_label)
deviceLabel?.text = selectedProfileLabel ?: ""
updatePreview(state.theme)
}
}
private val widgetThemeContext: Context by lazy {
ContextThemeWrapper(this, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
}
private fun resolveWidgetThemeColor(attr: Int): Int {
val typedArray = widgetThemeContext.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, Color.BLACK)
typedArray.recycle()
return color
}
private fun setupPreviewContainer() {
// Initial background set from XML, updated dynamically in updatePreview
}
private fun updatePreview(theme: WidgetTheme) {
val previewRoot = ui.previewCard.findViewById<View>(R.id.preview_widget_root)
// Background color applied to the inner view, matching the real widget
val bgColor = theme.backgroundColor
if (bgColor != null) {
previewRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
} else {
previewRoot.setBackgroundColor(resolveWidgetThemeColor(android.R.attr.colorBackground))
}
// Show checkerboard behind preview only when there's actual transparency to visualize
val hasTransparency = bgColor != null && theme.backgroundAlpha < 255
ui.previewContainer.background = if (hasTransparency) {
checkerboardDrawable
} else {
AppCompatResources.getDrawable(this, R.drawable.widget_preview_checkerboard)
}
// Foreground (text + icon colors)
val fgColor = theme.foregroundColor
val defaultTextColor = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
val defaultIconColor = resolveWidgetThemeColor(android.R.attr.colorAccent)
val textViews = listOf(
R.id.preview_left_label,
R.id.preview_right_label,
R.id.preview_case_label,
R.id.preview_device_label,
)
val iconViews = listOf(
R.id.preview_left_icon,
R.id.preview_right_icon,
R.id.preview_case_icon,
)
for (id in textViews) {
val tv = ui.previewCard.findViewById<android.widget.TextView>(id) ?: continue
tv.setTextColor(fgColor ?: defaultTextColor)
}
for (id in iconViews) {
val iv = ui.previewCard.findViewById<ImageView>(id) ?: continue
if (fgColor != null) {
iv.setColorFilter(fgColor, PorterDuff.Mode.SRC_IN)
} else {
iv.setColorFilter(defaultIconColor, PorterDuff.Mode.SRC_IN)
}
}
// Device label visibility
val deviceLabel = ui.previewCard.findViewById<View>(R.id.preview_device_label)
deviceLabel?.isVisible = theme.showDeviceLabel
}
private fun createCheckerboardDrawable(): BitmapDrawable {
val cellSize = (8 * resources.displayMetrics.density).toInt()
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
val canvas = Canvas(bitmap)
val paint = Paint()
// Light squares
paint.color = 0xFFE8E8E8.toInt()
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
// Dark squares
paint.color = 0xFFD0D0D0.toInt()
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
canvas.drawRect(
cellSize.toFloat(),
cellSize.toFloat(),
(cellSize * 2).toFloat(),
(cellSize * 2).toFloat(),
paint
)
return bitmap.toDrawable(resources).apply {
tileModeX = Shader.TileMode.REPEAT
tileModeY = Shader.TileMode.REPEAT
}
}
private fun setupPresetChips() {
val presetNames = mapOf(
WidgetTheme.Preset.MATERIAL_YOU to getString(R.string.widget_config_preset_material_you),
WidgetTheme.Preset.CLASSIC_DARK to getString(R.string.widget_config_preset_dark),
WidgetTheme.Preset.CLASSIC_LIGHT to getString(R.string.widget_config_preset_light),
WidgetTheme.Preset.BLUE to getString(R.string.widget_config_preset_blue),
WidgetTheme.Preset.GREEN to getString(R.string.widget_config_preset_green),
WidgetTheme.Preset.RED to getString(R.string.widget_config_preset_red),
)
for (preset in WidgetTheme.Preset.entries) {
val chip = Chip(this).apply {
text = presetNames[preset] ?: preset.name
isCheckable = true
tag = preset
setOnClickListener { vm.selectPreset(preset) }
}
ui.presetChipGroup.addView(chip)
}
// Custom chip
val customChip = Chip(this).apply {
text = getString(R.string.widget_config_custom_label)
isCheckable = true
tag = CUSTOM_CHIP_TAG
setOnClickListener {
val defaultBg = resolveWidgetThemeColor(android.R.attr.colorBackground)
val defaultFg = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
vm.enterCustomMode(defaultBg, defaultFg)
}
}
ui.presetChipGroup.addView(customChip)
}
private fun updatePresetChipSelection(activePreset: WidgetTheme.Preset?) {
for (i in 0 until ui.presetChipGroup.childCount) {
val chip = ui.presetChipGroup.getChildAt(i) as? Chip ?: continue
chip.isChecked = if (activePreset != null) {
chip.tag == activePreset
} else {
chip.tag == CUSTOM_CHIP_TAG
}
}
}
private fun updateCustomSectionsVisibility(isCustomMode: Boolean) {
val visibility = if (isCustomMode) View.VISIBLE else View.GONE
ui.bgColorLabel.visibility = visibility
ui.bgColorGrid.visibility = visibility
ui.bgHexInputLayout.visibility = visibility
ui.fgColorLabel.visibility = visibility
ui.fgColorGrid.visibility = visibility
ui.fgHexInputLayout.visibility = visibility
}
private fun setupColorSwatches(grid: GridLayout, isBg: Boolean) {
for (color in SWATCH_COLORS) {
val itemView = LayoutInflater.from(this)
.inflate(R.layout.widget_color_swatch_item, grid, false)
val swatchColor = itemView.findViewById<View>(R.id.swatch_color)
val bgDrawable = swatchColor.background?.mutate() as? GradientDrawable ?: continue
bgDrawable.setColor(color)
swatchColor.background = bgDrawable
itemView.setOnClickListener {
ui.bgHexInput.clearFocus()
ui.fgHexInput.clearFocus()
if (isBg) vm.setBackgroundColor(color) else vm.setForegroundColor(color)
}
val params = GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED),
GridLayout.spec(GridLayout.UNDEFINED, 1f),
).apply {
width = GridLayout.LayoutParams.WRAP_CONTENT
height = GridLayout.LayoutParams.WRAP_CONTENT
setGravity(Gravity.CENTER)
}
grid.addView(itemView, params)
}
}
private fun updateSwatchSelection(grid: GridLayout, selectedColor: Int?) {
for (i in 0 until grid.childCount) {
val itemView = grid.getChildAt(i) as? FrameLayout ?: continue
val color = SWATCH_COLORS.getOrNull(i) ?: continue
val isSelected =
selectedColor != null && (selectedColor or 0xFF000000.toInt()) == (color or 0xFF000000.toInt())
itemView.findViewById<View>(R.id.swatch_selected_ring)?.isVisible = isSelected
itemView.findViewById<ImageView>(R.id.swatch_check)?.apply {
isVisible = isSelected
if (isSelected) {
val checkColor = WidgetTheme.bestContrastForeground(color)
setColorFilter(checkColor)
setContent {
CapodTheme {
val state by waitForState(vm.state)
state?.let { currentState ->
WidgetConfigurationScreen(
state = currentState,
onSelectProfile = { profile -> vm.selectProfile(profile.id) },
onSelectPreset = { preset -> vm.selectPreset(preset) },
onEnterCustomMode = { bg, fg -> vm.enterCustomMode(bg, fg) },
onSetBackgroundColor = { color -> vm.setBackgroundColor(color) },
onSetForegroundColor = { color -> vm.setForegroundColor(color) },
onSetBackgroundAlpha = { alpha -> vm.setBackgroundAlpha(alpha) },
onSetShowDeviceLabel = { show -> vm.setShowDeviceLabel(show) },
onReset = { vm.resetToDefaults() },
onConfirm = {
if (currentState.isPro) {
confirmSelection()
} else {
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity)
}
},
onCancel = { finish() },
)
}
}
}
}
private fun setupHexInput() {
val hexFilter = InputFilter { source, _, _, _, _, _ ->
val filtered = source.toString().uppercase().filter { it in "0123456789ABCDEF" }
if (filtered == source.toString()) null else filtered
}
ui.bgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
ui.fgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
ui.bgHexInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
if (isUpdatingHexFromCode) return
val hex = s?.toString() ?: return
if (hex.length == 6) {
try {
val color = "#$hex".toColorInt()
vm.setBackgroundColor(color)
} catch (_: IllegalArgumentException) {
}
}
}
})
ui.fgHexInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
if (isUpdatingHexFromCode) return
val hex = s?.toString() ?: return
if (hex.length == 6) {
try {
val color = "#$hex".toColorInt()
vm.setForegroundColor(color)
} catch (_: IllegalArgumentException) {
}
}
}
})
}
private fun updateHexInputs(theme: WidgetTheme) {
isUpdatingHexFromCode = true
try {
val bgHex = theme.backgroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
if (ui.bgHexInput.text.toString() != bgHex && !ui.bgHexInput.hasFocus()) {
ui.bgHexInput.setText(bgHex)
}
val fgHex = theme.foregroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
if (ui.fgHexInput.text.toString() != fgHex && !ui.fgHexInput.hasFocus()) {
ui.fgHexInput.setText(fgHex)
}
} finally {
isUpdatingHexFromCode = false
}
}
private fun setupTransparencySlider() {
ui.transparencySlider.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
val alpha = 255 - (value / 100f * 255f).toInt()
vm.setBackgroundAlpha(alpha)
}
}
}
private fun setupShowDeviceLabelSwitch() {
ui.showDeviceLabelSwitch.setOnCheckedChangeListener { _, isChecked ->
vm.setShowDeviceLabel(isChecked)
}
}
private fun setupResetButton() {
ui.resetButton.setOnClickListener {
vm.resetToDefaults()
}
}
private fun confirmSelection() {
vm.confirmSelection()
@@ -452,33 +94,5 @@ class WidgetConfigurationActivity : Activity2() {
companion object {
private val TAG = logTag("Widget", "ConfigurationActivity")
private const val CUSTOM_CHIP_TAG = "custom"
private val SWATCH_COLORS = intArrayOf(
0xFFF44336.toInt(), // Red
0xFFE91E63.toInt(), // Pink
0xFF9C27B0.toInt(), // Purple
0xFF673AB7.toInt(), // Deep Purple
0xFF3F51B5.toInt(), // Indigo
0xFF2196F3.toInt(), // Blue
0xFF03A9F4.toInt(), // Light Blue
0xFF00BCD4.toInt(), // Cyan
0xFF009688.toInt(), // Teal
0xFF4CAF50.toInt(), // Green
0xFF8BC34A.toInt(), // Light Green
0xFFCDDC39.toInt(), // Lime
0xFFFFEB3B.toInt(), // Yellow
0xFFFFC107.toInt(), // Amber
0xFFFF9800.toInt(), // Orange
0xFFFF5722.toInt(), // Deep Orange
0xFF795548.toInt(), // Brown
0xFF9E9E9E.toInt(), // Grey
0xFF607D8B.toInt(), // Blue Grey
0xFFFFFFFF.toInt(), // White
0xFF1E1E1E.toInt(), // Near Black
0xFF37474F.toInt(), // Dark Blue Grey
0xFF1B5E20.toInt(), // Dark Green
0xFF0D47A1.toInt(), // Dark Blue
)
}
}
@@ -0,0 +1,678 @@
package eu.darken.capod.main.ui.widget
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.isVisible
import eu.darken.capod.R
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfile
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun WidgetConfigurationScreen(
state: WidgetConfigurationViewModel.State,
onSelectProfile: (DeviceProfile) -> Unit,
onSelectPreset: (WidgetTheme.Preset) -> Unit,
onEnterCustomMode: (defaultBg: Int, defaultFg: Int) -> Unit,
onSetBackgroundColor: (Int) -> Unit,
onSetForegroundColor: (Int) -> Unit,
onSetBackgroundAlpha: (Int) -> Unit,
onSetShowDeviceLabel: (Boolean) -> Unit,
onReset: () -> Unit,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars),
) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(top = 24.dp, bottom = 16.dp),
) {
// Profile selection header
Text(
text = stringResource(R.string.widget_configuration_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp),
)
Text(
text = stringResource(R.string.widget_configuration_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
)
Spacer(modifier = Modifier.height(12.dp))
// Profile list
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
state.profiles.forEach { profile ->
ProfileSelectionItem(
profile = profile,
isSelected = profile.id == state.selectedProfile,
onClick = { onSelectProfile(profile) },
)
Spacer(modifier = Modifier.height(8.dp))
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp))
// Appearance section header + reset
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.widget_config_appearance_label),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onReset) {
Text(text = stringResource(R.string.widget_config_reset_label))
}
}
Spacer(modifier = Modifier.height(12.dp))
// Live preview
WidgetPreview(
theme = state.theme,
deviceLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(16.dp))
// Transparency slider
val hasCustomBg = state.theme.backgroundColor != null
val transparencyPercent = ((255 - state.theme.backgroundAlpha) / 255f * 100f)
val displayPercent = (transparencyPercent / 5f).toInt() * 5
Text(
text = buildString {
append(stringResource(R.string.widget_config_transparency_label))
if (hasCustomBg && displayPercent > 0) append(" ($displayPercent%)")
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (hasCustomBg) 1f else 0.5f),
modifier = Modifier.padding(horizontal = 24.dp),
)
Slider(
value = displayPercent.toFloat(),
onValueChange = { value ->
val alpha = 255 - (value / 100f * 255f).toInt()
onSetBackgroundAlpha(alpha)
},
valueRange = 0f..100f,
steps = 19, // 0, 5, 10, ... 100 → 19 intermediate steps
enabled = hasCustomBg,
modifier = Modifier.padding(horizontal = 16.dp),
)
// Show device label switch
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.widget_config_show_device_label),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Switch(
checked = state.theme.showDeviceLabel,
onCheckedChange = onSetShowDeviceLabel,
)
}
Spacer(modifier = Modifier.height(16.dp))
// Preset chips
Text(
text = stringResource(R.string.widget_config_preset_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(8.dp))
PresetChips(
activePreset = state.activePreset,
isCustomMode = state.isCustomMode,
onSelectPreset = onSelectPreset,
onEnterCustomMode = onEnterCustomMode,
modifier = Modifier.padding(horizontal = 24.dp),
)
// Custom color sections (only visible in custom mode)
if (state.isCustomMode) {
Spacer(modifier = Modifier.height(16.dp))
// Background color
Text(
text = stringResource(R.string.widget_config_background_color_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(8.dp))
ColorSwatchGrid(
selectedColor = state.theme.backgroundColor,
onColorSelected = onSetBackgroundColor,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(8.dp))
HexColorInput(
color = state.theme.backgroundColor,
onColorChanged = onSetBackgroundColor,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(16.dp))
// Foreground color
Text(
text = stringResource(R.string.widget_config_foreground_color_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(8.dp))
ColorSwatchGrid(
selectedColor = state.theme.foregroundColor,
onColorSelected = onSetForegroundColor,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(8.dp))
HexColorInput(
color = state.theme.foregroundColor,
onColorChanged = onSetForegroundColor,
modifier = Modifier.padding(horizontal = 24.dp),
)
}
}
// Bottom bar
HorizontalDivider()
if (!state.isPro) {
Text(
text = stringResource(R.string.common_feature_requires_pro_msg),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(onClick = onCancel) {
Text(text = stringResource(android.R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = onConfirm,
enabled = if (state.isPro) state.canConfirm else true,
) {
Text(
text = if (state.isPro) {
stringResource(android.R.string.ok)
} else {
stringResource(R.string.general_upgrade_action)
}
)
}
}
}
}
@Composable
private fun ProfileSelectionItem(
profile: DeviceProfile,
isSelected: Boolean,
onClick: () -> Unit,
) {
OutlinedCard(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
border = if (isSelected) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(profile.model.iconRes),
contentDescription = null,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.label,
style = MaterialTheme.typography.bodyLarge,
)
val modelText = when (profile.model) {
PodDevice.Model.UNKNOWN -> stringResource(R.string.pods_unknown_label)
else -> profile.model.label
}
Text(
text = modelText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
RadioButton(
selected = isSelected,
onClick = null,
)
}
}
}
@Composable
private fun WidgetPreview(
theme: WidgetTheme,
deviceLabel: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val density = LocalDensity.current
val checkerboardDrawable = remember(density) {
val cellSize = (8 * context.resources.displayMetrics.density).toInt()
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
val canvas = Canvas(bitmap)
val paint = Paint()
paint.color = 0xFFE8E8E8.toInt()
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
paint.color = 0xFFD0D0D0.toInt()
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
canvas.drawRect(
cellSize.toFloat(), cellSize.toFloat(),
(cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint,
)
bitmap.toDrawable(context.resources).apply {
tileModeX = Shader.TileMode.REPEAT
tileModeY = Shader.TileMode.REPEAT
}
}
val resolvedBgColor = remember(context) {
resolveThemeColor(context, android.R.attr.colorBackground)
}
val resolvedTextColor = remember(context) {
resolveThemeColor(context, android.R.attr.textColorPrimary)
}
val resolvedAccentColor = remember(context) {
resolveThemeColor(context, android.R.attr.colorAccent)
}
Box(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center,
) {
AndroidView(
factory = { ctx ->
val container = android.widget.FrameLayout(ctx)
// Outer container for checkerboard
val outerPadding = (24 * ctx.resources.displayMetrics.density).toInt()
container.setPadding(outerPadding, outerPadding, outerPadding, outerPadding)
// Clip wrapper — rounded background + clipToOutline so the inner content is clipped
val clipWrapper = android.widget.FrameLayout(ctx).apply {
setBackgroundResource(R.drawable.widget_preview_bg)
clipToOutline = true
}
// Inner preview content
LayoutInflater.from(ctx).inflate(R.layout.widget_config_preview, clipWrapper, true)
container.addView(clipWrapper, android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
android.view.Gravity.CENTER,
))
container
},
update = { container ->
val hasTransparency = theme.backgroundColor != null && theme.backgroundAlpha < 255
container.background = if (hasTransparency) {
checkerboardDrawable
} else {
androidx.appcompat.content.res.AppCompatResources.getDrawable(
context, R.drawable.widget_preview_checkerboard
)
}
val clipWrapper = container.getChildAt(0) ?: return@AndroidView
val widgetRoot = clipWrapper.findViewById<View>(R.id.preview_widget_root) ?: return@AndroidView
// Background color applied to the inner view — clipWrapper's outline clips the corners
val bgColor = theme.backgroundColor
if (bgColor != null) {
widgetRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
} else {
widgetRoot.setBackgroundColor(resolvedBgColor)
}
// Foreground colors
val fgColor = theme.foregroundColor
val textColor = fgColor ?: resolvedTextColor
val iconColor = fgColor ?: resolvedAccentColor
val textIds = intArrayOf(
R.id.preview_left_label, R.id.preview_right_label,
R.id.preview_case_label, R.id.preview_device_label,
)
val iconIds = intArrayOf(
R.id.preview_left_icon, R.id.preview_right_icon, R.id.preview_case_icon,
)
for (id in textIds) {
clipWrapper.findViewById<TextView>(id)?.setTextColor(textColor)
}
for (id in iconIds) {
clipWrapper.findViewById<ImageView>(id)?.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
}
// Device label
val labelView = clipWrapper.findViewById<TextView>(R.id.preview_device_label)
labelView?.isVisible = theme.showDeviceLabel
labelView?.text = deviceLabel ?: ""
},
modifier = Modifier.fillMaxWidth(),
)
}
}
private fun resolveThemeColor(context: android.content.Context, attr: Int): Int {
val wrapper = androidx.appcompat.view.ContextThemeWrapper(
context,
com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight,
)
val typedArray = wrapper.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
typedArray.recycle()
return color
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PresetChips(
activePreset: WidgetTheme.Preset?,
isCustomMode: Boolean,
onSelectPreset: (WidgetTheme.Preset) -> Unit,
onEnterCustomMode: (defaultBg: Int, defaultFg: Int) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val presetNames = mapOf(
WidgetTheme.Preset.MATERIAL_YOU to stringResource(R.string.widget_config_preset_material_you),
WidgetTheme.Preset.CLASSIC_DARK to stringResource(R.string.widget_config_preset_dark),
WidgetTheme.Preset.CLASSIC_LIGHT to stringResource(R.string.widget_config_preset_light),
WidgetTheme.Preset.BLUE to stringResource(R.string.widget_config_preset_blue),
WidgetTheme.Preset.GREEN to stringResource(R.string.widget_config_preset_green),
WidgetTheme.Preset.RED to stringResource(R.string.widget_config_preset_red),
)
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
WidgetTheme.Preset.entries.forEach { preset ->
FilterChip(
selected = activePreset == preset,
onClick = { onSelectPreset(preset) },
label = { Text(presetNames[preset] ?: preset.name) },
)
}
FilterChip(
selected = isCustomMode,
onClick = {
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
val defaultFg = resolveThemeColor(context, android.R.attr.textColorPrimary)
onEnterCustomMode(defaultBg, defaultFg)
},
label = { Text(stringResource(R.string.widget_config_custom_label)) },
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ColorSwatchGrid(
selectedColor: Int?,
onColorSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
SWATCH_COLORS.forEach { color ->
val isSelected = selectedColor != null &&
(selectedColor or 0xFF000000.toInt()) == (color or 0xFF000000.toInt())
ColorSwatch(
color = color,
isSelected = isSelected,
onClick = { onColorSelected(color) },
)
}
}
}
@Composable
private fun ColorSwatch(
color: Int,
isSelected: Boolean,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(44.dp)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
// Color circle
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color(color))
.border(
width = if (isSelected) 3.dp else 1.dp,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
Color(0x20000000)
},
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
val checkColor = WidgetTheme.bestContrastForeground(color)
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(checkColor),
)
}
}
}
}
@Composable
private fun HexColorInput(
color: Int?,
onColorChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val hexString = color?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
var textFieldValue by remember { mutableStateOf(TextFieldValue(text = hexString, selection = TextRange(hexString.length))) }
// Sync from external color changes (e.g. swatch clicks) only when content genuinely differs
LaunchedEffect(hexString) {
val currentNormalized = textFieldValue.text.uppercase().filter { it in "0123456789ABCDEF" }
if (currentNormalized != hexString) {
textFieldValue = TextFieldValue(text = hexString, selection = TextRange(hexString.length))
}
}
OutlinedTextField(
value = textFieldValue,
onValueChange = { newValue ->
val filtered = newValue.text.uppercase().filter { it in "0123456789ABCDEF" }.take(6)
textFieldValue = newValue.copy(text = filtered)
if (filtered.length == 6) {
try {
val parsed = android.graphics.Color.parseColor("#$filtered")
onColorChanged(parsed)
} catch (_: IllegalArgumentException) {
}
}
},
label = { Text("#") },
singleLine = true,
modifier = modifier.width(160.dp),
)
}
private val SWATCH_COLORS = intArrayOf(
0xFFF44336.toInt(), // Red
0xFFE91E63.toInt(), // Pink
0xFF9C27B0.toInt(), // Purple
0xFF673AB7.toInt(), // Deep Purple
0xFF3F51B5.toInt(), // Indigo
0xFF2196F3.toInt(), // Blue
0xFF03A9F4.toInt(), // Light Blue
0xFF00BCD4.toInt(), // Cyan
0xFF009688.toInt(), // Teal
0xFF4CAF50.toInt(), // Green
0xFF8BC34A.toInt(), // Light Green
0xFFCDDC39.toInt(), // Lime
0xFFFFEB3B.toInt(), // Yellow
0xFFFFC107.toInt(), // Amber
0xFFFF9800.toInt(), // Orange
0xFFFF5722.toInt(), // Deep Orange
0xFF795548.toInt(), // Brown
0xFF9E9E9E.toInt(), // Grey
0xFF607D8B.toInt(), // Blue Grey
0xFFFFFFFF.toInt(), // White
0xFF1E1E1E.toInt(), // Near Black
0xFF37474F.toInt(), // Dark Blue Grey
0xFF1B5E20.toInt(), // Dark Green
0xFF0D47A1.toInt(), // Dark Blue
)
@@ -9,7 +9,8 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.common.flow.shareLatest
import eu.darken.capod.common.uix.ViewModel2
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
@@ -25,7 +26,7 @@ class WidgetConfigurationViewModel @Inject constructor(
private val widgetSettings: WidgetSettings,
private val upgradeRepo: UpgradeRepo,
@ApplicationContext private val context: Context,
) : ViewModel3(dispatcherProvider) {
) : ViewModel2(dispatcherProvider) {
private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) }
@@ -51,7 +52,7 @@ class WidgetConfigurationViewModel @Inject constructor(
private val currentTheme = MutableStateFlow(initialTheme)
val state = eu.darken.capod.common.flow.combine(
val state = combine(
selectedProfile,
currentTheme,
forceCustomMode,
@@ -69,7 +70,7 @@ class WidgetConfigurationViewModel @Inject constructor(
activePreset = activePreset,
isCustomMode = activePreset == null,
)
}.asLiveData2()
}.shareLatest(scope = vmScope)
data class State(
val profiles: List<DeviceProfile> = emptyList(),
@@ -1,35 +0,0 @@
package eu.darken.capod.main.ui.widget
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.AsyncDiffer
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.differ.HasAsyncDiffer
import eu.darken.capod.common.lists.differ.setupDiffer
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import javax.inject.Inject
class WidgetProfileSelectionAdapter @Inject constructor() :
ModularAdapter<WidgetProfileSelectionAdapter.BaseVH<WidgetProfileSelectionAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<WidgetProfileSelectionAdapter.Item> {
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
init {
modules.add(DataBinderMod(data))
modules.add(TypedVHCreatorMod({ data[it] is WidgetProfileSelectionVH.Item }) { WidgetProfileSelectionVH(it) })
}
override fun getItemCount(): Int = data.size
abstract class BaseVH<D : Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : VH(layoutId, parent), BindableVH<D, B>
interface Item : DifferItem
}
@@ -1,46 +0,0 @@
package eu.darken.capod.main.ui.widget
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.WidgetConfigurationProfileItemBinding
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfile
class WidgetProfileSelectionVH(parent: ViewGroup) :
WidgetProfileSelectionAdapter.BaseVH<WidgetProfileSelectionVH.Item, WidgetConfigurationProfileItemBinding>(
R.layout.widget_configuration_profile_item,
parent
) {
override val viewBinding = lazy {
WidgetConfigurationProfileItemBinding.bind(itemView)
}
override val onBindData: WidgetConfigurationProfileItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
val profile = item.profile
profileName.text = profile.label
val modelText = when (profile.model) {
PodDevice.Model.UNKNOWN -> context.getString(R.string.pods_unknown_label)
else -> profile.model.label
}
profileModel.text = modelText
profileIcon.setImageResource(profile.model.iconRes)
profileRadio.isChecked = item.isSelected
root.isChecked = item.isSelected
root.setOnClickListener { item.onProfileClick(profile) }
}
data class Item(
val profile: DeviceProfile,
val isSelected: Boolean,
val onProfileClick: (DeviceProfile) -> Unit,
) : WidgetProfileSelectionAdapter.Item {
override val stableId: Long = profile.id.hashCode().toLong()
}
}
@@ -1,54 +0,0 @@
package eu.darken.capod.profiles.ui
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.AsyncDiffer
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.differ.HasAsyncDiffer
import eu.darken.capod.common.lists.differ.setupDiffer
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import javax.inject.Inject
class DeviceManagerAdapter @Inject constructor() :
ModularAdapter<DeviceManagerAdapter.BaseVH<DeviceManagerAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<DeviceManagerAdapter.Item> {
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
init {
modules.add(DataBinderMod(data))
modules.add(TypedVHCreatorMod({ data[it] is DeviceProfileVH.Item }) { DeviceProfileVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is NoProfilesCardVH.Item }) { NoProfilesCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is PriorityHintVH.Item }) { PriorityHintVH(it) })
}
override fun getItemCount(): Int = data.size
fun moveItem(fromPosition: Int, toPosition: Int): Boolean {
if (fromPosition < 0 || toPosition < 0 || fromPosition >= data.size || toPosition >= data.size) {
return false
}
val currentData = data.toMutableList()
val item = currentData.removeAt(fromPosition)
currentData.add(toPosition, item)
// Update the adapter data through the differ for proper visual feedback
asyncDiffer.submitUpdate(currentData)
notifyItemMoved(fromPosition, toPosition)
return true
}
fun getItems(): List<Item> = data.toList()
abstract class BaseVH<D : Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B>
interface Item : DifferItem
}
@@ -1,110 +0,0 @@
package eu.darken.capod.profiles.ui
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.EdgeToEdgeHelper
import eu.darken.capod.common.lists.differ.update
import eu.darken.capod.common.lists.setupDefaults
import eu.darken.capod.common.uix.Fragment3
import eu.darken.capod.common.viewbinding.viewBinding
import eu.darken.capod.databinding.DeviceManagerFragmentBinding
import javax.inject.Inject
@AndroidEntryPoint
class DeviceManagerFragment : Fragment3(R.layout.device_manager_fragment) {
override val vm: DeviceManagerFragmentVM by viewModels()
override val ui: DeviceManagerFragmentBinding by viewBinding()
@Inject
lateinit var adapter: DeviceManagerAdapter
private var isDragging = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
EdgeToEdgeHelper(requireActivity()).apply {
insetsPadding(ui.root, left = true, right = true)
insetsPadding(ui.toolbar, top = true)
}
// Handle FAB margins for edge-to-edge
ViewCompat.setOnApplyWindowInsetsListener(ui.fab) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val baseMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 16f,
resources.displayMetrics
).toInt()
view.updateLayoutParams<androidx.constraintlayout.widget.ConstraintLayout.LayoutParams> {
bottomMargin = baseMargin + systemBars.bottom
marginEnd = baseMargin + systemBars.right
}
insets
}
ui.apply {
list.setupDefaults(adapter, dividers = false)
toolbar.setNavigationOnClickListener { vm.onBackPressed() }
fab.setOnClickListener { vm.onAddDevice() }
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPosition = viewHolder.adapterPosition
val toPosition = target.adapterPosition
// Only allow reordering of profile items, not empty state cards or hints
if (adapter.data[fromPosition] is DeviceProfileVH.Item &&
adapter.data[toPosition] is DeviceProfileVH.Item
) {
return adapter.moveItem(fromPosition, toPosition)
}
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// No swipe to dismiss
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
when (actionState) {
ItemTouchHelper.ACTION_STATE_DRAG -> {
isDragging = true
}
ItemTouchHelper.ACTION_STATE_IDLE -> {
if (isDragging) vm.onProfilesReordered(adapter.getItems())
}
}
}
override fun isLongPressDragEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = false
})
itemTouchHelper.attachToRecyclerView(list)
}
vm.listItems.observe2(ui) { items ->
adapter.update(items)
isDragging = false
}
super.onViewCreated(view, savedInstanceState)
}
}

Some files were not shown because too many files have changed in this diff Show More