mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -5,9 +5,10 @@ plugins {
|
|||||||
id("com.google.devtools.ksp")
|
id("com.google.devtools.ksp")
|
||||||
id("kotlin-kapt")
|
id("kotlin-kapt")
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
}
|
}
|
||||||
apply(plugin = "dagger.hilt.android.plugin")
|
apply(plugin = "dagger.hilt.android.plugin")
|
||||||
apply(plugin = "androidx.navigation.safeargs.kotlin")
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = projectConfig.compileSdk
|
compileSdk = projectConfig.compileSdk
|
||||||
@@ -97,6 +98,7 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -183,6 +185,10 @@ dependencies {
|
|||||||
|
|
||||||
addNavigation()
|
addNavigation()
|
||||||
|
|
||||||
|
addCompose()
|
||||||
|
addNavigation3()
|
||||||
|
addSerialization()
|
||||||
|
|
||||||
addTesting()
|
addTesting()
|
||||||
|
|
||||||
"gplayImplementation"("com.android.billingclient:billing:8.0.0")
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-92
@@ -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
|
package eu.darken.capod.main.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.navigation.findNavController
|
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.common.uix.Activity2
|
||||||
import eu.darken.capod.databinding.MainActivityBinding
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : Activity2() {
|
class MainActivity : Activity2() {
|
||||||
|
|
||||||
private val vm: MainActivityVM by viewModels()
|
@Inject lateinit var navCtrl: NavigationController
|
||||||
private lateinit var ui: MainActivityBinding
|
@Inject lateinit var navigationEntries: Set<@JvmSuppressWildcards NavigationEntry>
|
||||||
private val navController by lazy { supportFragmentManager.findNavController(R.id.nav_host) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
ui = MainActivityBinding.inflate(layoutInflater)
|
setContent {
|
||||||
setContentView(ui.root)
|
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
|
|
||||||
}
|
|
||||||
-44
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-21
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+26
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+86
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
-24
@@ -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
|
|
||||||
|
|
||||||
}
|
|
||||||
-23
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+26
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.os.Bundle
|
||||||
import android.text.Editable
|
import androidx.activity.compose.setContent
|
||||||
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.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.compose.runtime.getValue
|
||||||
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 dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.common.compose.waitForState
|
||||||
import eu.darken.capod.common.EdgeToEdgeHelper
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
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.uix.Activity2
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.databinding.WidgetConfigurationActivityBinding
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class WidgetConfigurationActivity : Activity2() {
|
class WidgetConfigurationActivity : Activity2() {
|
||||||
|
|
||||||
private val vm: WidgetConfigurationViewModel by viewModels()
|
private val vm: WidgetConfigurationViewModel by viewModels()
|
||||||
private lateinit var ui: WidgetConfigurationActivityBinding
|
|
||||||
|
|
||||||
@Inject lateinit var profileAdapter: WidgetProfileSelectionAdapter
|
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
@ApplicationContext @Inject lateinit var appContext: Context
|
@ApplicationContext @Inject lateinit var appContext: Context
|
||||||
|
|
||||||
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
|
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
private var isUpdatingHexFromCode = false
|
|
||||||
private val checkerboardDrawable: BitmapDrawable by lazy { createCheckerboardDrawable() }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -74,365 +47,34 @@ class WidgetConfigurationActivity : Activity2() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui = WidgetConfigurationActivityBinding.inflate(layoutInflater)
|
setContent {
|
||||||
setContentView(ui.root)
|
CapodTheme {
|
||||||
|
val state by waitForState(vm.state)
|
||||||
EdgeToEdgeHelper(this).apply {
|
state?.let { currentState ->
|
||||||
insetsPadding(ui.root, top = true, bottom = true, left = true, right = true)
|
WidgetConfigurationScreen(
|
||||||
}
|
state = currentState,
|
||||||
|
onSelectProfile = { profile -> vm.selectProfile(profile.id) },
|
||||||
ui.profilesRecycler.adapter = profileAdapter
|
onSelectPreset = { preset -> vm.selectPreset(preset) },
|
||||||
|
onEnterCustomMode = { bg, fg -> vm.enterCustomMode(bg, fg) },
|
||||||
setupPreviewContainer()
|
onSetBackgroundColor = { color -> vm.setBackgroundColor(color) },
|
||||||
setupPresetChips()
|
onSetForegroundColor = { color -> vm.setForegroundColor(color) },
|
||||||
setupColorSwatches(ui.bgColorGrid, isBg = true)
|
onSetBackgroundAlpha = { alpha -> vm.setBackgroundAlpha(alpha) },
|
||||||
setupColorSwatches(ui.fgColorGrid, isBg = false)
|
onSetShowDeviceLabel = { show -> vm.setShowDeviceLabel(show) },
|
||||||
setupHexInput()
|
onReset = { vm.resetToDefaults() },
|
||||||
setupTransparencySlider()
|
onConfirm = {
|
||||||
setupShowDeviceLabelSwitch()
|
if (currentState.isPro) {
|
||||||
setupResetButton()
|
confirmSelection()
|
||||||
|
} else {
|
||||||
ui.cancelButton.setOnClickListener {
|
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity)
|
||||||
log(TAG) { "Cancel clicked" }
|
}
|
||||||
finish()
|
},
|
||||||
}
|
onCancel = { 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
private fun confirmSelection() {
|
||||||
vm.confirmSelection()
|
vm.confirmSelection()
|
||||||
|
|
||||||
@@ -452,33 +94,5 @@ class WidgetConfigurationActivity : Activity2() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Widget", "ConfigurationActivity")
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.combine
|
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.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
@@ -25,7 +26,7 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
private val widgetSettings: WidgetSettings,
|
private val widgetSettings: WidgetSettings,
|
||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) : ViewModel3(dispatcherProvider) {
|
) : ViewModel2(dispatcherProvider) {
|
||||||
|
|
||||||
private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) }
|
private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) }
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val currentTheme = MutableStateFlow(initialTheme)
|
private val currentTheme = MutableStateFlow(initialTheme)
|
||||||
|
|
||||||
val state = eu.darken.capod.common.flow.combine(
|
val state = combine(
|
||||||
selectedProfile,
|
selectedProfile,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
forceCustomMode,
|
forceCustomMode,
|
||||||
@@ -69,7 +70,7 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
activePreset = activePreset,
|
activePreset = activePreset,
|
||||||
isCustomMode = activePreset == null,
|
isCustomMode = activePreset == null,
|
||||||
)
|
)
|
||||||
}.asLiveData2()
|
}.shareLatest(scope = vmScope)
|
||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
val profiles: List<DeviceProfile> = emptyList(),
|
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
Reference in New Issue
Block a user