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("kotlin-kapt")
|
||||
id("kotlin-parcelize")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
apply(plugin = "dagger.hilt.android.plugin")
|
||||
apply(plugin = "androidx.navigation.safeargs.kotlin")
|
||||
|
||||
android {
|
||||
compileSdk = projectConfig.compileSdk
|
||||
@@ -97,6 +98,7 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -183,6 +185,10 @@ dependencies {
|
||||
|
||||
addNavigation()
|
||||
|
||||
addCompose()
|
||||
addNavigation3()
|
||||
addSerialization()
|
||||
|
||||
addTesting()
|
||||
|
||||
"gplayImplementation"("com.android.billingclient:billing:8.0.0")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package eu.darken.capod.common.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
|
||||
@Composable
|
||||
fun PreviewWrapper(content: @Composable () -> Unit) {
|
||||
CapodTheme {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package eu.darken.capod.common.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Composable
|
||||
fun <T> waitForState(flow: Flow<T>): State<T?> {
|
||||
return produceState(initialValue = null) {
|
||||
flow.collect { value = it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package eu.darken.capod.common.error
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@Composable
|
||||
fun ErrorEventHandler(source: ErrorEventSource2) {
|
||||
val errorEvents = source.errorEvents
|
||||
var currentError by remember { mutableStateOf<Throwable?>(null) }
|
||||
|
||||
LaunchedEffect(errorEvents) { errorEvents.collect { error -> currentError = error } }
|
||||
|
||||
currentError?.let { error ->
|
||||
ComposeErrorDialog(
|
||||
throwable = error,
|
||||
onDismiss = { currentError = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComposeErrorDialog(
|
||||
throwable: Throwable,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(android.R.string.dialog_alert_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = throwable.localizedMessage
|
||||
?: throwable.message
|
||||
?: throwable::class.simpleName
|
||||
?: "Unknown error"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.darken.capod.common.error
|
||||
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
|
||||
interface ErrorEventSource2 {
|
||||
val errorEvents: SingleEventFlow<Throwable>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package eu.darken.capod.common.flow
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ChannelResult
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.AbstractFlow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
||||
class SingleEventFlow<T> : AbstractFlow<T>() {
|
||||
private val channel = Channel<T>(Channel.Factory.BUFFERED)
|
||||
|
||||
override suspend fun collectSafely(collector: FlowCollector<T>) = channel.receiveAsFlow().collect(collector)
|
||||
|
||||
suspend fun emit(value: T) = channel.send(value)
|
||||
|
||||
fun tryEmit(value: T): ChannelResult<Unit> = channel.trySend(value)
|
||||
|
||||
fun emitBlocking(value: T): ChannelResult<Unit> = channel.trySendBlocking(value)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
|
||||
fun Fragment.doNavigate(direction: NavDirections) = findNavController().doNavigate(direction)
|
||||
|
||||
fun Fragment.popBackStack(): Boolean {
|
||||
if (!isAdded) {
|
||||
IllegalStateException("Fragment is not added").also {
|
||||
log(WARN) { "Trying to pop backstack on Fragment that isn't added to an Activity: ${it.asLog()}" }
|
||||
}
|
||||
return false
|
||||
}
|
||||
return findNavController().popBackStack()
|
||||
}
|
||||
|
||||
/**
|
||||
* [FragmentContainerView] does not access [NavController] in [Activity.onCreate]
|
||||
* as workaround [FragmentManager] is used to get the [NavController]
|
||||
* @param id [Int] NavFragment id
|
||||
* @see <a href="https://issuetracker.google.com/issues/142847973">issue-142847973</a>
|
||||
*/
|
||||
@Throws(IllegalStateException::class)
|
||||
fun FragmentManager.findNavController(@IdRes id: Int): NavController {
|
||||
val fragment = findFragmentById(id) ?: throw IllegalStateException("Fragment is not found for id:$id")
|
||||
return NavHostFragment.findNavController(fragment)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
val LocalNavigationController = staticCompositionLocalOf<NavigationController?> { null }
|
||||
@@ -0,0 +1,42 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object Nav {
|
||||
sealed interface Main : NavigationDestination {
|
||||
@Serializable
|
||||
data object Overview : Main
|
||||
|
||||
@Serializable
|
||||
data object Onboarding : Main
|
||||
|
||||
@Serializable
|
||||
data object DeviceManager : Main
|
||||
|
||||
@Serializable
|
||||
data class DeviceProfileCreation(val profileId: String? = null) : Main
|
||||
|
||||
@Serializable
|
||||
data object TroubleShooter : Main
|
||||
}
|
||||
|
||||
sealed interface Settings : NavigationDestination {
|
||||
@Serializable
|
||||
data object Index : Settings
|
||||
|
||||
@Serializable
|
||||
data object General : Settings
|
||||
|
||||
@Serializable
|
||||
data object Reactions : Settings
|
||||
|
||||
@Serializable
|
||||
data object Debug : Settings
|
||||
|
||||
@Serializable
|
||||
data object Support : Settings
|
||||
|
||||
@Serializable
|
||||
data object Acknowledgements : Settings
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
|
||||
fun NavController.navigateIfNotThere(@IdRes resId: Int, args: Bundle? = null) {
|
||||
if (currentDestination?.id == resId) return
|
||||
navigate(resId, args)
|
||||
}
|
||||
|
||||
fun NavController.doNavigate(direction: NavDirections) {
|
||||
currentDestination?.getAction(direction.actionId)?.let { navigate(direction) }
|
||||
}
|
||||
|
||||
fun NavController.isGraphSet(): Boolean = try {
|
||||
graph
|
||||
true
|
||||
} catch (e: IllegalStateException) {
|
||||
false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.navigation.NavDestination
|
||||
|
||||
fun NavDestination?.hasAction(@IdRes id: Int): Boolean {
|
||||
if (this == null) return false
|
||||
return getAction(id) != null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
sealed interface NavEvent {
|
||||
data class GoTo(
|
||||
val destination: NavigationDestination,
|
||||
val popUpTo: NavigationDestination? = null,
|
||||
val inclusive: Boolean = false,
|
||||
) : NavEvent
|
||||
|
||||
data object Up : NavEvent
|
||||
|
||||
data object Finish : NavEvent
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NavigationController @Inject constructor() {
|
||||
private var _backStack: NavBackStack<NavKey>? = null
|
||||
|
||||
private val backStack: NavBackStack<NavKey>
|
||||
get() = _backStack ?: error("NavigationController not initialized")
|
||||
|
||||
fun setup(backStack: NavBackStack<NavKey>) {
|
||||
log(TAG) { "setup()" }
|
||||
_backStack = backStack
|
||||
}
|
||||
|
||||
fun up(): Boolean {
|
||||
if (backStack.size <= 1) {
|
||||
log(TAG) { "up() prevented removing the last element in backstack" }
|
||||
return false
|
||||
}
|
||||
val removed = backStack.removeLastOrNull()
|
||||
log(TAG) { "up() to ${backStack.lastOrNull()} (removed $removed)" }
|
||||
return removed != null
|
||||
}
|
||||
|
||||
fun goTo(
|
||||
destination: NavigationDestination,
|
||||
popUpTo: NavigationDestination? = null,
|
||||
inclusive: Boolean = false
|
||||
) {
|
||||
log(TAG) { "goTo($destination, popUpTo=$popUpTo, inclusive=$inclusive)" }
|
||||
|
||||
if (popUpTo != null) {
|
||||
if (backStack.none { it == popUpTo }) {
|
||||
log(TAG) { "popUpTo $popUpTo not found in backstack, skipping pop" }
|
||||
} else {
|
||||
while (backStack.isNotEmpty() && backStack.last() != popUpTo) {
|
||||
val removed = backStack.removeLastOrNull()
|
||||
log(TAG) { "Popping $removed while looking for $popUpTo" }
|
||||
}
|
||||
|
||||
if (inclusive && backStack.isNotEmpty() && backStack.last() == popUpTo) {
|
||||
val removed = backStack.removeLastOrNull()
|
||||
log(TAG) { "Popping $removed (inclusive)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backStack.add(destination)
|
||||
}
|
||||
|
||||
fun replace(destination: NavigationDestination) {
|
||||
backStack.removeLastOrNull()
|
||||
backStack.add(destination)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Navigation", "Controller")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
interface NavigationDestination : NavKey, java.io.Serializable
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
interface NavigationEntry {
|
||||
fun EntryProviderScope<NavKey>.setup()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
|
||||
@Composable
|
||||
fun NavigationEventHandler(vararg sources: NavigationEventSource) {
|
||||
val navController = LocalNavigationController.current ?: return
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
sources.forEach { source ->
|
||||
val navEvents = source.navEvents
|
||||
LaunchedEffect(navEvents) {
|
||||
navEvents.collect { event ->
|
||||
when (event) {
|
||||
is NavEvent.GoTo -> navController.goTo(
|
||||
destination = event.destination,
|
||||
popUpTo = event.popUpTo,
|
||||
inclusive = event.inclusive,
|
||||
)
|
||||
|
||||
NavEvent.Up -> navController.up()
|
||||
NavEvent.Finish -> {
|
||||
log(TAG) { "Finish event received, closing activity" }
|
||||
activity?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TAG = logTag("NavigationEventHandler")
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.darken.capod.common.navigation
|
||||
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
|
||||
interface NavigationEventSource {
|
||||
val navEvents: SingleEventFlow<NavEvent>
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AttributeSet
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.preference.Preference
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.R
|
||||
|
||||
|
||||
class IntentPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle,
|
||||
@StyleRes defStyleRes: Int = 0,
|
||||
) : Preference(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
override fun setIntent(_intent: Intent?) {
|
||||
super.setIntent(_intent)
|
||||
_intent?.let {
|
||||
intent
|
||||
setOnPreferenceClickListener {
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
log(TAG, ERROR) { "Failed to launch $intent: $e" }
|
||||
Toast.makeText(context, e.toString(), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("IntentPreference")
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.darken.capod.R
|
||||
|
||||
class MaterialSwitchPreference(context: Context, attrs: AttributeSet?) :
|
||||
SwitchPreferenceCompat(context, attrs) {
|
||||
|
||||
init {
|
||||
// Use material switch
|
||||
widgetLayoutResource = R.layout.preference_material_switch
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.TypedArray
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.preference.DialogPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.preferences.PercentSliderPreferenceDialogFragment.Companion.newInstance
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class PercentSliderPreference(context: Context, attrs: AttributeSet?) : DialogPreference(context, attrs) {
|
||||
@get:PluralsRes val sliderTextPluralsResource: Int
|
||||
|
||||
val min: Float
|
||||
val max: Float
|
||||
|
||||
private var internalValue = 0f
|
||||
private var internalValueSet = false
|
||||
|
||||
init {
|
||||
val a = getContext().obtainStyledAttributes(attrs, R.styleable.PercentSliderPreference)
|
||||
min = a.getFloat(R.styleable.PercentSliderPreference_pspMin, 0f)
|
||||
max = a.getFloat(R.styleable.PercentSliderPreference_pspMax, 1f)
|
||||
sliderTextPluralsResource = a.getResourceId(R.styleable.PercentSliderPreference_sliderText, 0)
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
// Always persist/notify the first time.
|
||||
var value: Float
|
||||
get() = internalValue
|
||||
set(value) {
|
||||
// Always persist/notify the first time.
|
||||
val changed = internalValue != value
|
||||
if (changed || !internalValueSet) {
|
||||
internalValue = value
|
||||
internalValueSet = true
|
||||
persistFloat(value)
|
||||
if (changed) notifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetDefaultValue(a: TypedArray, index: Int): Int {
|
||||
return a.getInteger(index, 0)
|
||||
}
|
||||
|
||||
override fun onSetInitialValue(restoreValue: Boolean, defaultValue: Any?) {
|
||||
value = if (restoreValue) getPersistedFloat(internalValue) else defaultValue as Float
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable? {
|
||||
val superState = super.onSaveInstanceState()
|
||||
// No need to save instance state since it's persistent
|
||||
if (isPersistent) return superState
|
||||
|
||||
return SavedState(value = value, superState = superState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
if (state?.javaClass != SavedState::class.java) {
|
||||
// Didn't save state for us in onSaveInstanceState
|
||||
return super.onRestoreInstanceState(state)
|
||||
}
|
||||
|
||||
val myState = state as SavedState
|
||||
super.onRestoreInstanceState(myState.superState)
|
||||
value = myState.value
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class SavedState(
|
||||
val value: Float,
|
||||
val superState: Parcelable?,
|
||||
) : Parcelable
|
||||
|
||||
companion object {
|
||||
private const val DIALOG_FRAGMENT_TAG = "android.support.v7.preference.PreferenceFragment.DIALOG"
|
||||
fun onDisplayPreferenceDialog(preferenceFragment: PreferenceFragmentCompat, preference: Preference): Boolean {
|
||||
if (preference is PercentSliderPreference) {
|
||||
val fragmentManager = preferenceFragment.fragmentManager
|
||||
if (fragmentManager!!.findFragmentByTag(DIALOG_FRAGMENT_TAG) == null) {
|
||||
val dialogFragment = newInstance(preference.getKey())
|
||||
dialogFragment.setTargetFragment(preferenceFragment, 0)
|
||||
dialogFragment.show(fragmentManager, DIALOG_FRAGMENT_TAG)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
-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
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.navigation.findNavController
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.navigation.LocalNavigationController
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationController
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
import eu.darken.capod.common.uix.Activity2
|
||||
import eu.darken.capod.databinding.MainActivityBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : Activity2() {
|
||||
|
||||
private val vm: MainActivityVM by viewModels()
|
||||
private lateinit var ui: MainActivityBinding
|
||||
private val navController by lazy { supportFragmentManager.findNavController(R.id.nav_host) }
|
||||
@Inject lateinit var navCtrl: NavigationController
|
||||
@Inject lateinit var navigationEntries: Set<@JvmSuppressWildcards NavigationEntry>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
ui = MainActivityBinding.inflate(layoutInflater)
|
||||
setContentView(ui.root)
|
||||
setContent {
|
||||
val backStack = rememberNavBackStack(Nav.Main.Overview)
|
||||
navCtrl.setup(backStack)
|
||||
|
||||
CapodTheme {
|
||||
CompositionLocalProvider(LocalNavigationController provides navCtrl) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
if (!navCtrl.up()) {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
entryProvider = entryProvider {
|
||||
navigationEntries.forEach { entry ->
|
||||
entry.apply {
|
||||
log(TAG) { "Set up navigation entry: $this" }
|
||||
setup()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MainActivity")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package eu.darken.capod.main.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.uix.ViewModel2
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class MainActivityVM @Inject constructor(
|
||||
handle: SavedStateHandle,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
) : ViewModel2(dispatcherProvider = dispatcherProvider)
|
||||
@@ -1,33 +0,0 @@
|
||||
package eu.darken.capod.main.ui.onboarding
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.EdgeToEdgeHelper
|
||||
import eu.darken.capod.common.PrivacyPolicy
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.uix.Fragment3
|
||||
import eu.darken.capod.common.viewbinding.viewBinding
|
||||
import eu.darken.capod.databinding.OnboardingFragmentBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class OnboardingFragment : Fragment3(R.layout.onboarding_fragment) {
|
||||
|
||||
override val vm: OnboardingFragmentVM by viewModels()
|
||||
override val ui: OnboardingFragmentBinding by viewBinding()
|
||||
|
||||
@Inject lateinit var webpageTool: WebpageTool
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
EdgeToEdgeHelper(requireActivity()).apply {
|
||||
insetsPadding(ui.root, left = true, right = true, top = true, bottom = true)
|
||||
}
|
||||
ui.goPrivacyPolicy.setOnClickListener { webpageTool.open(PrivacyPolicy.URL) }
|
||||
ui.continueAction.setOnClickListener { vm.finishOnboarding() }
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package eu.darken.capod.main.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.uix.ViewModel3
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class OnboardingFragmentVM @Inject constructor(
|
||||
@Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val generalSettings: GeneralSettings,
|
||||
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
|
||||
|
||||
fun finishOnboarding() = launch {
|
||||
generalSettings.isOnboardingDone.value = true
|
||||
OnboardingFragmentDirections.actionOnboardingFragmentToOverviewFragment().navigate()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = logTag("Onboarding", "Fragment", "VM")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.darken.capod.main.ui.onboarding
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class OnboardingNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.Onboarding> { OnboardingScreenHost() }
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: OnboardingNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package eu.darken.capod.main.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.waitForState
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreenHost(vm: OnboardingViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
OnboardingScreen(
|
||||
onPrivacyPolicy = { vm.openPrivacyPolicy() },
|
||||
onContinue = { vm.finishOnboarding() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onPrivacyPolicy: () -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
) {
|
||||
Scaffold { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 32.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.splash_graphic2),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_body1),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_body2),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_body3),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_body4),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
FilledTonalButton(onClick = onPrivacyPolicy) {
|
||||
Text(text = stringResource(R.string.settings_privacy_policy_label))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
) {
|
||||
Text(text = stringResource(R.string.general_continue_action))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.darken.capod.main.ui.onboarding
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.PrivacyPolicy
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
fun openPrivacyPolicy() {
|
||||
webpageTool.open(PrivacyPolicy.URL)
|
||||
}
|
||||
|
||||
fun finishOnboarding() = launch {
|
||||
generalSettings.isOnboardingDone.value = true
|
||||
navTo(Nav.Main.Overview, popUpTo = Nav.Main.Onboarding, inclusive = true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Onboarding", "VM")
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import eu.darken.capod.common.lists.BindableVH
|
||||
import eu.darken.capod.common.lists.differ.AsyncDiffer
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.common.lists.differ.HasAsyncDiffer
|
||||
import eu.darken.capod.common.lists.differ.setupDiffer
|
||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
|
||||
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
|
||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveVH
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesVH
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
|
||||
import javax.inject.Inject
|
||||
|
||||
class OverviewAdapter @Inject constructor() :
|
||||
ModularAdapter<OverviewAdapter.BaseVH<OverviewAdapter.Item, ViewBinding>>(),
|
||||
HasAsyncDiffer<OverviewAdapter.Item> {
|
||||
|
||||
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
|
||||
|
||||
init {
|
||||
modules.add(DataBinderMod(data))
|
||||
modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is DualPodsCardVH.Item }) { DualPodsCardVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is SinglePodsCardVH.Item }) { SinglePodsCardVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is NoProfilesVH.Item }) { NoProfilesVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is MonitoringActiveVH.Item }) { MonitoringActiveVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is UnmatchedDevicesCardVH.Item }) { UnmatchedDevicesCardVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = data.size
|
||||
|
||||
abstract class BaseVH<D : Item, B : ViewBinding>(
|
||||
@LayoutRes layoutId: Int,
|
||||
parent: ViewGroup
|
||||
) : VH(layoutId, parent), BindableVH<D, B>
|
||||
|
||||
interface Item : DifferItem
|
||||
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.EdgeToEdgeHelper
|
||||
import eu.darken.capod.common.colorString
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.lists.differ.update
|
||||
import eu.darken.capod.common.lists.setupDefaults
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.uix.Fragment3
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.viewbinding.viewBinding
|
||||
import eu.darken.capod.databinding.MainFragmentBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class OverviewFragment : Fragment3(R.layout.main_fragment) {
|
||||
|
||||
override val vm: OverviewFragmentVM by viewModels()
|
||||
override val ui: MainFragmentBinding by viewBinding()
|
||||
|
||||
@Inject
|
||||
lateinit var adapter: OverviewAdapter
|
||||
|
||||
lateinit var permissionLauncher: ActivityResultLauncher<String>
|
||||
var awaitingPermission = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
awaitingPermission = savedInstanceState?.getBoolean("awaitingPermission") ?: false
|
||||
|
||||
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
log { "Request for $id was granted=$granted" }
|
||||
vm.onPermissionResult(granted)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
EdgeToEdgeHelper(requireActivity()).apply {
|
||||
insetsPadding(ui.root, left = true, right = true)
|
||||
insetsPadding(ui.toolbar, top = true)
|
||||
insetsPadding(ui.list, bottom = false)
|
||||
}
|
||||
ui.apply {
|
||||
list.setupDefaults(adapter, dividers = false)
|
||||
}
|
||||
|
||||
ui.toolbar.apply {
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_item_devices -> {
|
||||
vm.goToDeviceManager()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.menu_item_settings -> {
|
||||
vm.goToSettings()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.menu_item_donate -> {
|
||||
vm.onUpgrade()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.menu_item_upgrade -> {
|
||||
vm.onUpgrade()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.listItems.observe2(ui) { adapter.update(it) }
|
||||
|
||||
vm.workerAutolaunch.observe2 {
|
||||
// While UI is active, subscribe to the autolaunch routine
|
||||
}
|
||||
|
||||
vm.requestPermissionEvent.observe2(ui) {
|
||||
when (it) {
|
||||
Permission.IGNORE_BATTERY_OPTIMIZATION -> {
|
||||
awaitingPermission = true
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
"package:${requireContext().packageName}".toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Permission.SYSTEM_ALERT_WINDOW -> {
|
||||
awaitingPermission = true
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
"package:${requireContext().packageName}".toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
permissionLauncher.launch(it.permissionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.upgradeState.observe2(ui) { info ->
|
||||
val gplay = toolbar.menu.findItem(R.id.menu_item_upgrade)
|
||||
val donate = toolbar.menu.findItem(R.id.menu_item_donate)
|
||||
gplay.isVisible = false
|
||||
donate.isVisible = false
|
||||
|
||||
val baseTitle = when (info.type) {
|
||||
UpgradeRepo.Type.GPLAY -> {
|
||||
if (info.isPro) {
|
||||
getString(R.string.app_name_pro)
|
||||
} else {
|
||||
gplay.isVisible = true
|
||||
getString(R.string.app_name)
|
||||
}
|
||||
}
|
||||
|
||||
UpgradeRepo.Type.FOSS -> {
|
||||
if (info.isPro) {
|
||||
getString(R.string.app_name_foss)
|
||||
} else {
|
||||
donate.isVisible = true
|
||||
getString(R.string.app_name)
|
||||
}
|
||||
}
|
||||
}.split(" ".toRegex())
|
||||
.dropLastWhile { it.isEmpty() }
|
||||
.toTypedArray()
|
||||
|
||||
toolbar.title = if (baseTitle.size == 2) {
|
||||
val builder = SpannableStringBuilder(baseTitle[0] + " ")
|
||||
val color = when (info.type) {
|
||||
UpgradeRepo.Type.FOSS -> R.color.brand_secondary
|
||||
else -> R.color.brand_tertiary
|
||||
}
|
||||
builder.append(colorString(requireContext(), color, baseTitle[1]))
|
||||
} else {
|
||||
getString(R.string.app_name)
|
||||
}
|
||||
}
|
||||
vm.launchUpgradeFlow.observe2 {
|
||||
it(requireActivity())
|
||||
}
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putBoolean("awaitingPermission", awaitingPermission)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (awaitingPermission) {
|
||||
awaitingPermission = false
|
||||
log { "awaitingPermission=true" }
|
||||
vm.onPermissionResult(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.combine
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.livedata.SingleLiveEvent
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.uix.ViewModel3
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveVH
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesVH
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
|
||||
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class OverviewFragmentVM @Inject constructor(
|
||||
@Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val monitorControl: MonitorControl,
|
||||
private val podMonitor: PodMonitor,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val generalSettings: GeneralSettings,
|
||||
debugSettings: DebugSettings,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
|
||||
|
||||
init {
|
||||
if (!generalSettings.isOnboardingDone.value) {
|
||||
OverviewFragmentDirections.actionOverviewFragmentToOnboardingFragment().navigate()
|
||||
}
|
||||
}
|
||||
|
||||
val upgradeState = upgradeRepo.upgradeInfo
|
||||
.onEach {
|
||||
if (!it.isPro && it.error != null) {
|
||||
errorEvents.postValue(it.error)
|
||||
}
|
||||
}
|
||||
.asLiveData2()
|
||||
val launchUpgradeFlow = SingleLiveEvent<(Activity) -> Unit>()
|
||||
|
||||
private val updateTicker = channelFlow<Unit> {
|
||||
while (isActive) {
|
||||
trySend(Unit)
|
||||
delay(3000)
|
||||
}
|
||||
}
|
||||
|
||||
val workerAutolaunch: LiveData<Unit> = permissionTool.missingPermissions
|
||||
.onEach {
|
||||
if (it.isNotEmpty()) {
|
||||
log(TAG) { "Missing permissions: $it" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val shouldStartMonitor = when (generalSettings.monitorMode.value) {
|
||||
MonitorMode.MANUAL -> false
|
||||
MonitorMode.AUTOMATIC -> bluetoothManager.connectedDevices.first().isNotEmpty()
|
||||
MonitorMode.ALWAYS -> true
|
||||
}
|
||||
if (shouldStartMonitor) {
|
||||
log(TAG) { "Starting monitor" }
|
||||
monitorControl.startMonitor()
|
||||
}
|
||||
}
|
||||
.map { }
|
||||
.asLiveData2()
|
||||
|
||||
val requestPermissionEvent = SingleLiveEvent<Permission>()
|
||||
|
||||
private var showUnmatchedDevices = false
|
||||
|
||||
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
|
||||
.flatMapLatest { permissions ->
|
||||
if (permissions.isNotEmpty()) {
|
||||
return@flatMapLatest flowOf(emptyList())
|
||||
}
|
||||
|
||||
podMonitor.devices
|
||||
}
|
||||
.catch { errorEvents.postValue(it) }
|
||||
.throttleLatest(1000)
|
||||
|
||||
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
|
||||
updateTicker,
|
||||
permissionTool.missingPermissions,
|
||||
pods,
|
||||
debugSettings.isDebugModeEnabled.flow,
|
||||
bluetoothManager.isBluetoothEnabled,
|
||||
profilesRepo.profiles,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles ->
|
||||
val items = mutableListOf<OverviewAdapter.Item>()
|
||||
|
||||
permissions
|
||||
.map { perm ->
|
||||
PermissionCardVH.Item(
|
||||
permission = perm,
|
||||
onRequest = { requestPermissionEvent.postValue(it) },
|
||||
)
|
||||
}
|
||||
.run { items.addAll(this) }
|
||||
|
||||
if (permissions.isEmpty()) {
|
||||
if (!isBluetoothEnabled) {
|
||||
items.add(0, BluetoothDisabledVH.Item)
|
||||
} else if (profiles.isEmpty()) {
|
||||
items.add(
|
||||
0, NoProfilesVH.Item(
|
||||
onManageDevices = {
|
||||
OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate()
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if (permissions.isEmpty() && isBluetoothEnabled) {
|
||||
val now = Instant.now()
|
||||
|
||||
// Split devices into profiled and unmatched
|
||||
val profiledDevices = devices.filter { it.meta.profile != null }
|
||||
val unmatchedDevices = devices.filter { it.meta.profile == null }
|
||||
|
||||
// Add profiled devices first
|
||||
profiledDevices.map { device ->
|
||||
when (device) {
|
||||
is DualPodDevice -> DualPodsCardVH.Item(
|
||||
now = now,
|
||||
device = device,
|
||||
showDebug = isDebugMode,
|
||||
)
|
||||
|
||||
is SinglePodDevice -> SinglePodsCardVH.Item(
|
||||
now = now,
|
||||
device = device,
|
||||
showDebug = isDebugMode,
|
||||
)
|
||||
|
||||
else -> UnknownPodDeviceCardVH.Item(
|
||||
now = now,
|
||||
device = device,
|
||||
showDebug = isDebugMode,
|
||||
)
|
||||
}
|
||||
}.run { items.addAll(this) }
|
||||
|
||||
if (profiles.isNotEmpty() && devices.isEmpty()) {
|
||||
items.add(MonitoringActiveVH.Item)
|
||||
}
|
||||
|
||||
// Add unmatched devices section if any exist
|
||||
if (unmatchedDevices.isNotEmpty()) {
|
||||
items.add(UnmatchedDevicesCardVH.Item(
|
||||
count = unmatchedDevices.size,
|
||||
isExpanded = showUnmatchedDevices,
|
||||
onToggle = { toggleUnmatchedDevices() }
|
||||
))
|
||||
|
||||
// Show unmatched devices if expanded
|
||||
if (showUnmatchedDevices) {
|
||||
unmatchedDevices.map { device ->
|
||||
when (device) {
|
||||
is DualPodDevice -> DualPodsCardVH.Item(
|
||||
now = now,
|
||||
device = device,
|
||||
showDebug = isDebugMode,
|
||||
)
|
||||
|
||||
is SinglePodDevice -> SinglePodsCardVH.Item(
|
||||
now = now,
|
||||
device = device,
|
||||
showDebug = isDebugMode,
|
||||
)
|
||||
|
||||
else -> UnknownPodDeviceCardVH.Item(
|
||||
now = now,
|
||||
device = device,
|
||||
showDebug = isDebugMode,
|
||||
)
|
||||
}
|
||||
}.run { items.addAll(this) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
.catch { errorEvents.postValue(it) }
|
||||
.asLiveData2()
|
||||
|
||||
fun onPermissionResult(granted: Boolean) {
|
||||
if (granted) permissionTool.recheck()
|
||||
}
|
||||
|
||||
fun goToSettings() = launch {
|
||||
OverviewFragmentDirections.actionOverviewFragmentToSettingsFragment().navigate()
|
||||
}
|
||||
|
||||
fun goToDeviceManager() = launch {
|
||||
OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate()
|
||||
}
|
||||
|
||||
fun onUpgrade() = launch {
|
||||
val call: (Activity) -> Unit = {
|
||||
upgradeRepo.launchBillingFlow(it)
|
||||
}
|
||||
launchUpgradeFlow.postValue(call)
|
||||
}
|
||||
|
||||
private fun toggleUnmatchedDevices() {
|
||||
showUnmatchedDevices = !showUnmatchedDevices
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Overview", "OverviewFragmentVM")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class OverviewNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.Overview> { OverviewScreenHost() }
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: OverviewNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.waitForState
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
||||
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
// Collect workerAutolaunch passively to keep it active
|
||||
LaunchedEffect(Unit) {
|
||||
vm.workerAutolaunch.collect {}
|
||||
}
|
||||
|
||||
// Permission handling
|
||||
var awaitingPermission by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
vm.onPermissionResult(granted)
|
||||
}
|
||||
|
||||
// When returning from settings-based permissions
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (awaitingPermission) {
|
||||
awaitingPermission = false
|
||||
vm.onPermissionResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle permission request events
|
||||
LaunchedEffect(Unit) {
|
||||
vm.requestPermissionEvent.collect { permission ->
|
||||
when (permission) {
|
||||
Permission.IGNORE_BATTERY_OPTIMIZATION -> {
|
||||
awaitingPermission = true
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
"package:${context.packageName}".toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Permission.SYSTEM_ALERT_WINDOW -> {
|
||||
awaitingPermission = true
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
"package:${context.packageName}".toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
permissionLauncher.launch(permission.permissionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle upgrade flow events
|
||||
LaunchedEffect(Unit) {
|
||||
vm.launchUpgradeFlow.collect { action ->
|
||||
activity?.let { action(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val stateHolder = waitForState(vm.state)
|
||||
val state = stateHolder.value ?: return
|
||||
|
||||
OverviewScreen(
|
||||
state = state,
|
||||
onRequestPermission = { vm.requestPermission(it) },
|
||||
onManageDevices = { vm.goToDeviceManager() },
|
||||
onSettings = { vm.goToSettings() },
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OverviewScreen(
|
||||
state: OverviewViewModel.State,
|
||||
onRequestPermission: (Permission) -> Unit,
|
||||
onManageDevices: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onUpgrade: () -> Unit,
|
||||
onToggleUnmatched: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
ToolbarTitle(upgradeInfo = state.upgradeInfo)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onManageDevices) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_devices_other_24),
|
||||
contentDescription = stringResource(R.string.settings_devices_label),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onSettings) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_settings_24),
|
||||
contentDescription = stringResource(R.string.settings_general_label),
|
||||
)
|
||||
}
|
||||
|
||||
// Upgrade/donate button based on type and pro status
|
||||
val info = state.upgradeInfo
|
||||
when {
|
||||
info.type == UpgradeRepo.Type.GPLAY && !info.isPro -> {
|
||||
IconButton(onClick = onUpgrade) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_stars_24),
|
||||
contentDescription = stringResource(R.string.general_upgrade_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
info.type == UpgradeRepo.Type.FOSS && !info.isPro -> {
|
||||
IconButton(onClick = onUpgrade) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_heart_24),
|
||||
contentDescription = stringResource(R.string.general_donate_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
// 1. Permission cards
|
||||
items(
|
||||
items = state.permissions.toList(),
|
||||
key = { it.permissionId },
|
||||
) { permission ->
|
||||
PermissionCard(
|
||||
permission = permission,
|
||||
onRequest = onRequestPermission,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Bluetooth disabled card
|
||||
if (!state.isBluetoothEnabled && state.permissions.isEmpty()) {
|
||||
item(key = "bluetooth_disabled") {
|
||||
BluetoothDisabledCard()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No profiles card
|
||||
if (state.profiles.isEmpty() && state.permissions.isEmpty() && state.isBluetoothEnabled) {
|
||||
item(key = "no_profiles") {
|
||||
NoProfilesCard(onManageDevices = onManageDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Profiled device cards
|
||||
if (state.permissions.isEmpty() && state.isBluetoothEnabled) {
|
||||
items(
|
||||
items = state.profiledDevices,
|
||||
key = { it.identifier.hashCode() },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
}
|
||||
|
||||
// 5. Monitoring active card
|
||||
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
|
||||
item(key = "monitoring_active") {
|
||||
MonitoringActiveCard()
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Unmatched devices section
|
||||
if (state.unmatchedDevices.isNotEmpty()) {
|
||||
item(key = "unmatched_header") {
|
||||
UnmatchedDevicesCard(
|
||||
count = state.unmatchedDevices.size,
|
||||
isExpanded = state.showUnmatchedDevices,
|
||||
onToggle = onToggleUnmatched,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showUnmatchedDevices) {
|
||||
items(
|
||||
items = state.unmatchedDevices,
|
||||
key = { "unmatched_${it.identifier.hashCode()}" },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
|
||||
when (device) {
|
||||
is DualPodDevice -> DualPodsCard(device = device, showDebug = showDebug, now = now)
|
||||
is SinglePodDevice -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
|
||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarTitle(upgradeInfo: UpgradeRepo.Info) {
|
||||
val appName = stringResource(R.string.app_name)
|
||||
val proName = stringResource(R.string.app_name_pro)
|
||||
val fossName = stringResource(R.string.app_name_foss)
|
||||
|
||||
val titleParts = when (upgradeInfo.type) {
|
||||
UpgradeRepo.Type.GPLAY -> {
|
||||
if (upgradeInfo.isPro) proName else appName
|
||||
}
|
||||
|
||||
UpgradeRepo.Type.FOSS -> {
|
||||
if (upgradeInfo.isPro) fossName else appName
|
||||
}
|
||||
}.split(" ").filter { it.isNotEmpty() }
|
||||
|
||||
if (titleParts.size == 2) {
|
||||
val suffixColor = when (upgradeInfo.type) {
|
||||
UpgradeRepo.Type.FOSS -> colorResource(R.color.brand_secondary)
|
||||
else -> colorResource(R.color.brand_tertiary)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append("${titleParts[0]} ")
|
||||
withStyle(SpanStyle(color = suffixColor)) {
|
||||
append(titleParts[1])
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Text(text = appName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import android.app.Activity
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.flow.combine
|
||||
import eu.darken.capod.common.flow.shareLatest
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class OverviewViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val monitorControl: MonitorControl,
|
||||
private val podMonitor: PodMonitor,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val generalSettings: GeneralSettings,
|
||||
debugSettings: DebugSettings,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
init {
|
||||
if (!generalSettings.isOnboardingDone.value) {
|
||||
navTo(Nav.Main.Onboarding, popUpTo = Nav.Main.Overview, inclusive = true)
|
||||
}
|
||||
}
|
||||
|
||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||
val launchUpgradeFlow = SingleEventFlow<(Activity) -> Unit>()
|
||||
|
||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||
|
||||
val workerAutolaunch = permissionTool.missingPermissions
|
||||
.onEach { permissions ->
|
||||
if (permissions.isNotEmpty()) {
|
||||
log(TAG) { "Missing permissions: $permissions" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val shouldStart = when (generalSettings.monitorMode.value) {
|
||||
MonitorMode.MANUAL -> false
|
||||
MonitorMode.AUTOMATIC -> {
|
||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
||||
devices?.isNotEmpty() == true
|
||||
}
|
||||
MonitorMode.ALWAYS -> true
|
||||
}
|
||||
if (shouldStart) {
|
||||
log(TAG) { "Starting monitor" }
|
||||
monitorControl.startMonitor()
|
||||
}
|
||||
}
|
||||
.shareLatest(scope = vmScope)
|
||||
|
||||
private val updateTicker = channelFlow<Unit> {
|
||||
while (isActive) {
|
||||
trySend(Unit)
|
||||
delay(3000)
|
||||
}
|
||||
}
|
||||
|
||||
private val pods = permissionTool.missingPermissions
|
||||
.flatMapLatest { permissions ->
|
||||
if (permissions.isNotEmpty()) {
|
||||
return@flatMapLatest flowOf(emptyList())
|
||||
}
|
||||
podMonitor.devices
|
||||
}
|
||||
.catch { errorEvents.emitBlocking(it) }
|
||||
.throttleLatest(1000)
|
||||
|
||||
val state = combine(
|
||||
updateTicker,
|
||||
permissionTool.missingPermissions,
|
||||
pods,
|
||||
debugSettings.isDebugModeEnabled.flow,
|
||||
bluetoothManager.isBluetoothEnabled,
|
||||
profilesRepo.profiles,
|
||||
upgradeRepo.upgradeInfo,
|
||||
showUnmatchedDevices,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched ->
|
||||
State(
|
||||
now = Instant.now(),
|
||||
permissions = permissions,
|
||||
devices = devices,
|
||||
isDebugMode = isDebugMode,
|
||||
isBluetoothEnabled = isBluetoothEnabled,
|
||||
profiles = profiles,
|
||||
upgradeInfo = upgradeInfo,
|
||||
showUnmatchedDevices = showUnmatched,
|
||||
)
|
||||
}.shareLatest(scope = vmScope)
|
||||
|
||||
data class State(
|
||||
val now: Instant,
|
||||
val permissions: Set<Permission>,
|
||||
val devices: List<PodDevice>,
|
||||
val isDebugMode: Boolean,
|
||||
val isBluetoothEnabled: Boolean,
|
||||
val profiles: List<DeviceProfile>,
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
val showUnmatchedDevices: Boolean,
|
||||
) {
|
||||
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
|
||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
|
||||
}
|
||||
|
||||
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
|
||||
permissionTool.recheck()
|
||||
}
|
||||
|
||||
fun goToSettings() {
|
||||
navTo(Nav.Settings.Index)
|
||||
}
|
||||
|
||||
fun goToDeviceManager() {
|
||||
navTo(Nav.Main.DeviceManager)
|
||||
}
|
||||
|
||||
fun onUpgrade() = launch {
|
||||
launchUpgradeFlow.tryEmit { upgradeRepo.launchBillingFlow(it) }
|
||||
}
|
||||
|
||||
fun toggleUnmatchedDevices() {
|
||||
showUnmatchedDevices.value = !showUnmatchedDevices.value
|
||||
}
|
||||
|
||||
fun requestPermission(permission: Permission) {
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Overview", "VM")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
|
||||
@Composable
|
||||
fun BluetoothDisabledCard() {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.overview_bluetooth_disabled_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.overview_bluetooth_disabled_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import android.view.ViewGroup
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.databinding.OverviewBluetoothDisabledItemBinding
|
||||
import eu.darken.capod.main.ui.overview.OverviewAdapter
|
||||
|
||||
class BluetoothDisabledVH(parent: ViewGroup) :
|
||||
OverviewAdapter.BaseVH<BluetoothDisabledVH.Item, OverviewBluetoothDisabledItemBinding>(
|
||||
R.layout.overview_bluetooth_disabled_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
OverviewBluetoothDisabledItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: OverviewBluetoothDisabledItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> Unit = binding(payload = true) { item ->
|
||||
|
||||
}
|
||||
|
||||
object Item : OverviewAdapter.Item {
|
||||
override val stableId: Long = Item::class.hashCode().toLong()
|
||||
|
||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
||||
get() = { old, new -> if (new::class.isInstance(old)) new else null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryChargingFull
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Hearing
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.SettingsInputAntenna
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.HasPodStyle
|
||||
import eu.darken.capod.pods.core.HasStateDetection
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun DualPodsCard(
|
||||
device: DualPodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
// Header: device icon + name + type on left, signal in top-right
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(device.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Device name + key icon
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = device.meta.profile?.label ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (device is ApplePods && device.meta.isIRKMatch) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = if (device.payload.private != null) Icons.Default.Key else Icons.Outlined.Key,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
val deviceLabel = buildString {
|
||||
append(device.getLabel(context))
|
||||
if (device is HasPodStyle && showDebug) {
|
||||
append(" (${device.podStyle.getColor(context)})")
|
||||
}
|
||||
if (device is DualApplePods && showDebug) {
|
||||
append(" [${device.primaryPod.name}]")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = deviceLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Signal quality + antenna icon
|
||||
Text(
|
||||
text = device.getSignalQuality(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.SettingsInputAntenna,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Last seen
|
||||
Text(
|
||||
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// First seen (only show if > 1 minute has passed)
|
||||
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
|
||||
Text(
|
||||
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Left pod battery
|
||||
BatteryRow(
|
||||
label = "L",
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeftPodPercent,
|
||||
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
|
||||
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
|
||||
showEarDetection = device is HasEarDetectionDual,
|
||||
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device is HasDualMicrophone,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Right pod battery
|
||||
BatteryRow(
|
||||
label = "R",
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRightPodPercent,
|
||||
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
|
||||
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
|
||||
showEarDetection = device is HasEarDetectionDual,
|
||||
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device is HasDualMicrophone,
|
||||
)
|
||||
|
||||
// Case battery + lid state
|
||||
if (device is HasCase) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BatteryRow(
|
||||
label = "C",
|
||||
iconRes = device.caseIcon,
|
||||
batteryPercent = device.batteryCasePercent,
|
||||
isCharging = device.isCaseCharging,
|
||||
isInEar = false,
|
||||
showEarDetection = false,
|
||||
isMicrophone = false,
|
||||
showMicrophone = false,
|
||||
)
|
||||
|
||||
// Case lid state (below case row, aligned with label text)
|
||||
if (device is DualApplePods) {
|
||||
val lidState = device.caseLidState
|
||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 32.dp, top = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.GridView,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = when (lidState) {
|
||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection state
|
||||
if (device is HasStateDetection) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = device.state.getLabel(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Debug info
|
||||
if (showDebug) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "--- Debug ---",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = device.rawDataHex.joinToString("\n"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BatteryRow(
|
||||
label: String,
|
||||
iconRes: Int,
|
||||
batteryPercent: Float?,
|
||||
isCharging: Boolean,
|
||||
isInEar: Boolean,
|
||||
showEarDetection: Boolean,
|
||||
isMicrophone: Boolean,
|
||||
showMicrophone: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = "$label: ${formatBatteryPercent(context, batteryPercent)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.width(80.dp),
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { batteryPercent ?: 0f },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
)
|
||||
|
||||
// Status icons — fixed width, always rendered, alpha toggles visibility so positions stay stable
|
||||
Row(
|
||||
modifier = Modifier.width(52.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.BatteryChargingFull,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.alpha(if (isCharging) 1f else 0f),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardVoice,
|
||||
contentDescription = stringResource(R.string.pods_microphone_label),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.alpha(if (showMicrophone && isMicrophone) 1f else 0f),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Hearing,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.alpha(if (showEarDetection && isInEar) 1f else 0f),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
|
||||
@Composable
|
||||
fun MonitoringActiveCard() {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.overview_monitoring_active_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.overview_monitoring_active_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import android.view.ViewGroup
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.databinding.OverviewMonitoringActiveItemBinding
|
||||
import eu.darken.capod.main.ui.overview.OverviewAdapter
|
||||
|
||||
class MonitoringActiveVH(parent: ViewGroup) :
|
||||
OverviewAdapter.BaseVH<MonitoringActiveVH.Item, OverviewMonitoringActiveItemBinding>(
|
||||
R.layout.overview_monitoring_active_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
OverviewMonitoringActiveItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: OverviewMonitoringActiveItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> Unit = binding(payload = true) { item ->
|
||||
// No actions needed for this card - it's purely informational
|
||||
}
|
||||
|
||||
object Item : OverviewAdapter.Item {
|
||||
override val stableId: Long = Item::class.hashCode().toLong()
|
||||
|
||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
||||
get() = { old, new -> if (new::class.isInstance(old)) new else null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
|
||||
@Composable
|
||||
fun NoProfilesCard(onManageDevices: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.overview_nomaindevice_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.overview_nomaindevice_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = onManageDevices,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_devices_other_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(text = stringResource(R.string.general_manage_devices_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import android.view.ViewGroup
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.databinding.OverviewNoprofilesItemBinding
|
||||
import eu.darken.capod.main.ui.overview.OverviewAdapter
|
||||
|
||||
class NoProfilesVH(parent: ViewGroup) :
|
||||
OverviewAdapter.BaseVH<NoProfilesVH.Item, OverviewNoprofilesItemBinding>(
|
||||
R.layout.overview_noprofiles_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
OverviewNoprofilesItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: OverviewNoprofilesItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> Unit = binding(payload = true) { item ->
|
||||
manageDevicesAction.setOnClickListener { item.onManageDevices() }
|
||||
}
|
||||
|
||||
data class Item(
|
||||
val onManageDevices: () -> Unit,
|
||||
) : OverviewAdapter.Item {
|
||||
override val stableId: Long = Item::class.hashCode().toLong()
|
||||
|
||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
||||
get() = { old, new -> if (new::class.isInstance(old)) new else null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
|
||||
@Composable
|
||||
fun PermissionCard(
|
||||
permission: Permission,
|
||||
onRequest: (Permission) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(permission.labelRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(permission.descriptionRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onRequest(permission) },
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(text = stringResource(R.string.general_grant_permission_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import android.text.Html
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.PrivacyPolicy
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.databinding.OverviewPermissionItemBinding
|
||||
import eu.darken.capod.main.ui.overview.OverviewAdapter
|
||||
|
||||
class PermissionCardVH(parent: ViewGroup) :
|
||||
OverviewAdapter.BaseVH<PermissionCardVH.Item, OverviewPermissionItemBinding>(
|
||||
R.layout.overview_permission_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
OverviewPermissionItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: OverviewPermissionItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> Unit = binding(payload = true) { item ->
|
||||
permissionLabel.setText(item.permission.labelRes)
|
||||
permissionDescription.setText(item.permission.descriptionRes)
|
||||
grantAction.setOnClickListener { item.onRequest(item.permission) }
|
||||
privacyPolicy.apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
val ppText = getString(R.string.settings_privacy_policy_label)
|
||||
val ppLink = PrivacyPolicy.URL
|
||||
text = Html.fromHtml("<html><a href=\"$ppLink\">$ppText</a></html>", 0)
|
||||
val ppp = setOf(
|
||||
Permission.ACCESS_FINE_LOCATION,
|
||||
Permission.ACCESS_BACKGROUND_LOCATION,
|
||||
Permission.BLUETOOTH_SCAN
|
||||
)
|
||||
isGone = !ppp.contains(item.permission)
|
||||
}
|
||||
}
|
||||
|
||||
data class Item(
|
||||
val permission: Permission,
|
||||
val onRequest: (Permission) -> Unit
|
||||
) : OverviewAdapter.Item {
|
||||
override val stableId: Long = permission.hashCode().toLong()
|
||||
|
||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
||||
get() = { old, new -> if (new::class.isInstance(old)) new else null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryChargingFull
|
||||
import androidx.compose.material.icons.filled.Hearing
|
||||
import androidx.compose.material.icons.filled.SettingsInputAntenna
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun SinglePodsCard(
|
||||
device: SinglePodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
// Header: name + device icon on left, signal on right
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(device.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.meta.profile?.label ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = device.getLabel(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Signal quality + antenna icon
|
||||
Text(
|
||||
text = device.getSignalQuality(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.SettingsInputAntenna,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Last seen
|
||||
Text(
|
||||
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// First seen (only show if > 1 minute has passed)
|
||||
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
|
||||
Text(
|
||||
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Battery level
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryHeadsetPercent),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.width(48.dp),
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { device.batteryHeadsetPercent ?: 0f },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.width(60.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (device is HasChargeDetection && device.isHeadsetBeingCharged) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.BatteryChargingFull,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
if (device is HasEarDetection && device.isBeingWorn) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Hearing,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug info
|
||||
if (showDebug) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "--- Debug ---",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = device.rawDataHex.joinToString("\n"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SettingsInputAntenna
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun UnknownPodDeviceCard(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
// Header with signal in top-right
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = device.getLabel(context),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = device.getSignalQuality(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.SettingsInputAntenna,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = when (device) {
|
||||
is ApplePods -> stringResource(R.string.pods_unknown_contact_dev)
|
||||
else -> stringResource(R.string.pods_unknown_label)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
if (showDebug) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.pods_unknown_raw_data_label),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = device.rawDataHex.joinToString("\n"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
|
||||
@Composable
|
||||
fun UnmatchedDevicesCard(
|
||||
count: Int,
|
||||
isExpanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.overview_unmatched_devices_count, count, count),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(onClick = onToggle) {
|
||||
Text(
|
||||
text = if (isExpanded) {
|
||||
stringResource(R.string.general_hide_action)
|
||||
} else {
|
||||
stringResource(R.string.general_show_action)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import android.view.ViewGroup
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.databinding.OverviewUnmatchedDevicesItemBinding
|
||||
import eu.darken.capod.main.ui.overview.OverviewAdapter
|
||||
|
||||
class UnmatchedDevicesCardVH(parent: ViewGroup) :
|
||||
OverviewAdapter.BaseVH<UnmatchedDevicesCardVH.Item, OverviewUnmatchedDevicesItemBinding>(
|
||||
R.layout.overview_unmatched_devices_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
OverviewUnmatchedDevicesItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: OverviewUnmatchedDevicesItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> Unit = binding(payload = true) { item ->
|
||||
unmatchedCount.text = getQuantityString(R.plurals.overview_unmatched_devices_count, item.count, item.count)
|
||||
|
||||
val toggleText = if (item.isExpanded) {
|
||||
context.getString(R.string.general_hide_action)
|
||||
} else {
|
||||
context.getString(R.string.general_show_action)
|
||||
}
|
||||
toggleAction.text = toggleText
|
||||
|
||||
toggleAction.setOnClickListener { item.onToggle() }
|
||||
}
|
||||
|
||||
data class Item(
|
||||
val count: Int,
|
||||
val isExpanded: Boolean,
|
||||
val onToggle: () -> Unit,
|
||||
) : OverviewAdapter.Item {
|
||||
override val stableId: Long = Item::class.hashCode().toLong()
|
||||
|
||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
||||
get() = { old, new -> if (new::class.isInstance(old)) new else null }
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards.pods
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.databinding.OverviewPodsDualItemBinding
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.HasPodStyle
|
||||
import eu.darken.capod.pods.core.HasStateDetection
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
class DualPodsCardVH(parent: ViewGroup) :
|
||||
PodDeviceVH<DualPodsCardVH.Item, OverviewPodsDualItemBinding>(
|
||||
R.layout.overview_pods_dual_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy { OverviewPodsDualItemBinding.bind(itemView) }
|
||||
|
||||
override val onBindData = binding(payload = true) { item: Item ->
|
||||
val device = item.device
|
||||
|
||||
name.text = device.meta.profile?.label ?: "?"
|
||||
|
||||
deviceType.apply {
|
||||
val sb = StringBuilder(device.getLabel(context))
|
||||
if (device is HasPodStyle && item.showDebug) {
|
||||
val style = device.podStyle
|
||||
sb.append(" (${style.getColor(context)})")
|
||||
}
|
||||
text = sb
|
||||
|
||||
if (device is DualApplePods && item.showDebug) {
|
||||
append(" [${device.primaryPod.name}]")
|
||||
}
|
||||
}
|
||||
deviceIcon.setImageResource(device.iconRes)
|
||||
podLeftIcon.setImageResource(device.leftPodIcon)
|
||||
podRightIcon.setImageResource(device.rightPodIcon)
|
||||
|
||||
lastSeen.text =
|
||||
context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
|
||||
firstSeen.text =
|
||||
context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
|
||||
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
|
||||
|
||||
reception.text = item.getReceptionText()
|
||||
|
||||
keyIcon.apply {
|
||||
isVisible = device is ApplePods && device.meta.isIRKMatch
|
||||
if (device !is ApplePods) return@apply
|
||||
setImageResource(
|
||||
when {
|
||||
device.payload.private != null -> R.drawable.ic_key_24
|
||||
else -> R.drawable.ic_key_outline_24
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Pods battery state
|
||||
device.apply {
|
||||
val leftPercent = batteryLeftPodPercent
|
||||
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
|
||||
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
|
||||
|
||||
val rightPercent = batteryRightPodPercent
|
||||
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
|
||||
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
|
||||
}
|
||||
|
||||
// Pods charging state
|
||||
device.apply {
|
||||
if (this is HasChargeDetectionDual) {
|
||||
podLeftChargingIcon.isInvisible = !isLeftPodCharging
|
||||
podLeftChargingLabel.isInvisible = !isLeftPodCharging
|
||||
|
||||
podRightChargingIcon.isInvisible = !isRightPodCharging
|
||||
podRightChargingLabel.isInvisible = !isRightPodCharging
|
||||
} else {
|
||||
podLeftChargingIcon.isGone = true
|
||||
podLeftChargingLabel.isGone = true
|
||||
|
||||
podRightChargingIcon.isGone = true
|
||||
podRightChargingLabel.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
// Microphone state
|
||||
device.apply {
|
||||
if (this is HasDualMicrophone) {
|
||||
podLeftMicrophoneIcon.isInvisible = !isLeftPodMicrophone
|
||||
podLeftMicrophoneLabel.isInvisible = !isLeftPodMicrophone
|
||||
|
||||
podRightMicrophoneIcon.isInvisible = !isRightPodMicrophone
|
||||
podRightMicrophoneLabel.isInvisible = !isRightPodMicrophone
|
||||
} else {
|
||||
podLeftMicrophoneIcon.isGone = true
|
||||
podLeftMicrophoneLabel.isGone = true
|
||||
|
||||
podRightMicrophoneIcon.isGone = true
|
||||
podRightMicrophoneLabel.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pods wear state
|
||||
device.apply {
|
||||
if (this is HasEarDetectionDual) {
|
||||
podLeftWearIcon.isInvisible = !isLeftPodInEar
|
||||
podLeftWearLabel.isInvisible = !isLeftPodInEar
|
||||
|
||||
podRightWearIcon.isInvisible = !isRightPodInEar
|
||||
podRightWearLabel.isInvisible = !isRightPodInEar
|
||||
} else {
|
||||
podLeftWearIcon.isGone = true
|
||||
podLeftWearLabel.isGone = true
|
||||
|
||||
podRightWearIcon.isGone = true
|
||||
podRightWearLabel.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
// Case charge state
|
||||
device.apply {
|
||||
if (this is HasCase) {
|
||||
podCaseIcon.setImageResource(caseIcon)
|
||||
podCaseBatteryIcon.isGone = false
|
||||
val casePercent = batteryCasePercent
|
||||
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
|
||||
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
|
||||
|
||||
podCaseChargingIcon.isInvisible = !isCaseCharging
|
||||
podCaseChargingLabel.isInvisible = !isCaseCharging
|
||||
} else {
|
||||
podCaseBatteryIcon.isGone = true
|
||||
podCaseBatteryLabel.isGone = true
|
||||
|
||||
podCaseChargingIcon.isGone = true
|
||||
podCaseChargingLabel.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
// Case lid state
|
||||
device.apply {
|
||||
if (this is DualApplePods) {
|
||||
podCaseLidLabel.text = when (caseLidState) {
|
||||
LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
|
||||
else -> context.getString(R.string.pods_case_unknown_state)
|
||||
}
|
||||
|
||||
val hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
|
||||
podCaseLidIcon.isInvisible = hideInfo
|
||||
podCaseLidLabel.isInvisible = hideInfo
|
||||
} else {
|
||||
podCaseLidIcon.isGone = true
|
||||
podCaseLidLabel.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
// Connection state
|
||||
device.apply {
|
||||
val sb = StringBuilder()
|
||||
if (this is HasStateDetection) {
|
||||
sb.append(state.getLabel(context))
|
||||
}
|
||||
if (item.showDebug) {
|
||||
sb.append("\n\n").append("---Debug---")
|
||||
sb.append("\n").append(rawDataHex)
|
||||
}
|
||||
status.text = sb
|
||||
status.isGone = sb.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
data class Item(
|
||||
override val now: Instant,
|
||||
override val device: DualPodDevice,
|
||||
override val showDebug: Boolean,
|
||||
) : PodDeviceVH.Item
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards.pods
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import eu.darken.capod.common.lists.BindableVH
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||
import eu.darken.capod.main.ui.overview.OverviewAdapter
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import java.time.Instant
|
||||
|
||||
abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
|
||||
@LayoutRes layoutId: Int,
|
||||
parent: ViewGroup
|
||||
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B> {
|
||||
|
||||
fun Item.getReceptionText(): String = device.getSignalQuality(context)
|
||||
.let { if (showDebug) "$it ${device.seenCounter}" else it }
|
||||
|
||||
interface Item : OverviewAdapter.Item {
|
||||
|
||||
val now: Instant
|
||||
|
||||
val device: PodDevice
|
||||
|
||||
val showDebug: Boolean
|
||||
|
||||
override val stableId: Long get() = device.identifier.hashCode().toLong()
|
||||
|
||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
|
||||
get() = { old, new ->
|
||||
if (new::class.isInstance(old)) new else null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package eu.darken.capod.main.ui.overview.cards.pods
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.databinding.OverviewPodsSingleItemBinding
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
class SinglePodsCardVH(parent: ViewGroup) :
|
||||
PodDeviceVH<SinglePodsCardVH.Item, OverviewPodsSingleItemBinding>(
|
||||
R.layout.overview_pods_single_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy { OverviewPodsSingleItemBinding.bind(itemView) }
|
||||
|
||||
override val onBindData = binding(payload = true) { item: Item ->
|
||||
val device = item.device
|
||||
|
||||
name.text = device.meta.profile?.label ?: "?"
|
||||
deviceType.text = device.getLabel(context)
|
||||
|
||||
deviceIcon.setImageResource(device.iconRes)
|
||||
|
||||
lastSeen.text =
|
||||
context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
|
||||
firstSeen.text =
|
||||
context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
|
||||
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
|
||||
|
||||
reception.text = item.getReceptionText()
|
||||
|
||||
keyIcon.apply {
|
||||
isVisible = device is ApplePods && device.meta.isIRKMatch
|
||||
if (device !is ApplePods) return@apply
|
||||
setImageResource(
|
||||
when {
|
||||
device.payload.private != null -> R.drawable.ic_key_24
|
||||
else -> R.drawable.ic_key_outline_24
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Battery level
|
||||
device.apply {
|
||||
val headsetPercent = batteryHeadsetPercent
|
||||
batteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
|
||||
batteryLabel.text = formatBatteryPercent(context, headsetPercent)
|
||||
}
|
||||
|
||||
// Charge state
|
||||
device.apply {
|
||||
if (this is HasChargeDetection) {
|
||||
chargingIcon.isInvisible = !isHeadsetBeingCharged
|
||||
chargingLabel.isInvisible = !isHeadsetBeingCharged
|
||||
} else {
|
||||
chargingIcon.isGone = true
|
||||
chargingLabel.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
// Has ear detection
|
||||
device.apply {
|
||||
if (this is HasEarDetection) {
|
||||
wearIcon.isInvisible = !isBeingWorn
|
||||
wearLabel.isInvisible = !isBeingWorn
|
||||
} else {
|
||||
wearIcon.isGone = true
|
||||
wearLabel.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
status.apply {
|
||||
val sb = StringBuilder()
|
||||
if (item.showDebug) {
|
||||
sb.append("--- Debug ---")
|
||||
sb.append("\n").append(device.rawDataHex)
|
||||
}
|
||||
text = sb
|
||||
isGone = !item.showDebug
|
||||
}
|
||||
}
|
||||
|
||||
data class Item(
|
||||
override val now: Instant,
|
||||
override val device: SinglePodDevice,
|
||||
override val showDebug: Boolean,
|
||||
) : PodDeviceVH.Item
|
||||
}
|
||||
-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.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Shader
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.InputFilter
|
||||
import android.text.TextWatcher
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.GridLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.chip.Chip
|
||||
import androidx.compose.runtime.getValue
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.EdgeToEdgeHelper
|
||||
import eu.darken.capod.common.compose.waitForState
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
import eu.darken.capod.common.uix.Activity2
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.databinding.WidgetConfigurationActivityBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WidgetConfigurationActivity : Activity2() {
|
||||
|
||||
private val vm: WidgetConfigurationViewModel by viewModels()
|
||||
private lateinit var ui: WidgetConfigurationActivityBinding
|
||||
|
||||
@Inject lateinit var profileAdapter: WidgetProfileSelectionAdapter
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
@ApplicationContext @Inject lateinit var appContext: Context
|
||||
|
||||
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
private var isUpdatingHexFromCode = false
|
||||
private val checkerboardDrawable: BitmapDrawable by lazy { createCheckerboardDrawable() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
@@ -74,365 +47,34 @@ class WidgetConfigurationActivity : Activity2() {
|
||||
return
|
||||
}
|
||||
|
||||
ui = WidgetConfigurationActivityBinding.inflate(layoutInflater)
|
||||
setContentView(ui.root)
|
||||
|
||||
EdgeToEdgeHelper(this).apply {
|
||||
insetsPadding(ui.root, top = true, bottom = true, left = true, right = true)
|
||||
}
|
||||
|
||||
ui.profilesRecycler.adapter = profileAdapter
|
||||
|
||||
setupPreviewContainer()
|
||||
setupPresetChips()
|
||||
setupColorSwatches(ui.bgColorGrid, isBg = true)
|
||||
setupColorSwatches(ui.fgColorGrid, isBg = false)
|
||||
setupHexInput()
|
||||
setupTransparencySlider()
|
||||
setupShowDeviceLabelSwitch()
|
||||
setupResetButton()
|
||||
|
||||
ui.cancelButton.setOnClickListener {
|
||||
log(TAG) { "Cancel clicked" }
|
||||
finish()
|
||||
}
|
||||
|
||||
ui.confirmButton.setOnClickListener {
|
||||
if (vm.state.value?.isPro == true) {
|
||||
log(TAG) { "Confirm clicked" }
|
||||
confirmSelection()
|
||||
} else {
|
||||
log(TAG) { "Upgrade clicked" }
|
||||
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity)
|
||||
}
|
||||
}
|
||||
|
||||
vm.state.observe2 { state ->
|
||||
val adapterItems = state.profiles.map { profile ->
|
||||
WidgetProfileSelectionVH.Item(
|
||||
profile = profile,
|
||||
isSelected = profile.id == state.selectedProfile,
|
||||
onProfileClick = { vm.selectProfile(it.id) }
|
||||
)
|
||||
}
|
||||
profileAdapter.asyncDiffer.submitUpdate(adapterItems)
|
||||
|
||||
ui.proRequiredCaption.isVisible = !state.isPro
|
||||
|
||||
if (state.isPro) {
|
||||
ui.confirmButton.text = getString(android.R.string.ok)
|
||||
ui.confirmButton.isEnabled = state.canConfirm
|
||||
} else {
|
||||
ui.confirmButton.text = getString(R.string.general_upgrade_action)
|
||||
ui.confirmButton.isEnabled = true
|
||||
}
|
||||
|
||||
updatePresetChipSelection(state.activePreset)
|
||||
updateCustomSectionsVisibility(state.isCustomMode)
|
||||
updateSwatchSelection(ui.bgColorGrid, state.theme.backgroundColor)
|
||||
updateSwatchSelection(ui.fgColorGrid, state.theme.foregroundColor)
|
||||
updateHexInputs(state.theme)
|
||||
|
||||
// Disable transparency slider when using Material You (no custom bg to apply alpha to)
|
||||
val hasCustomBg = state.theme.backgroundColor != null
|
||||
ui.transparencySlider.isEnabled = hasCustomBg
|
||||
ui.transparencyLabel.alpha = if (hasCustomBg) 1.0f else 0.5f
|
||||
|
||||
val transparencyPercent = ((255 - state.theme.backgroundAlpha) / 255f * 100f)
|
||||
val displayPercent = (transparencyPercent / 5f).toInt() * 5
|
||||
if (!ui.transparencySlider.isPressed) {
|
||||
ui.transparencySlider.value = displayPercent.toFloat()
|
||||
}
|
||||
ui.transparencyLabel.text = getString(
|
||||
R.string.widget_config_transparency_label
|
||||
) + if (hasCustomBg && displayPercent > 0) " ($displayPercent%)" else ""
|
||||
|
||||
ui.showDeviceLabelSwitch.isChecked = state.theme.showDeviceLabel
|
||||
|
||||
val selectedProfileLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label
|
||||
val deviceLabel = ui.previewCard.findViewById<android.widget.TextView>(R.id.preview_device_label)
|
||||
deviceLabel?.text = selectedProfileLabel ?: ""
|
||||
|
||||
updatePreview(state.theme)
|
||||
}
|
||||
}
|
||||
|
||||
private val widgetThemeContext: Context by lazy {
|
||||
ContextThemeWrapper(this, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
|
||||
}
|
||||
|
||||
private fun resolveWidgetThemeColor(attr: Int): Int {
|
||||
val typedArray = widgetThemeContext.theme.obtainStyledAttributes(intArrayOf(attr))
|
||||
val color = typedArray.getColor(0, Color.BLACK)
|
||||
typedArray.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
private fun setupPreviewContainer() {
|
||||
// Initial background set from XML, updated dynamically in updatePreview
|
||||
}
|
||||
|
||||
private fun updatePreview(theme: WidgetTheme) {
|
||||
val previewRoot = ui.previewCard.findViewById<View>(R.id.preview_widget_root)
|
||||
|
||||
// Background color applied to the inner view, matching the real widget
|
||||
val bgColor = theme.backgroundColor
|
||||
if (bgColor != null) {
|
||||
previewRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
|
||||
} else {
|
||||
previewRoot.setBackgroundColor(resolveWidgetThemeColor(android.R.attr.colorBackground))
|
||||
}
|
||||
|
||||
// Show checkerboard behind preview only when there's actual transparency to visualize
|
||||
val hasTransparency = bgColor != null && theme.backgroundAlpha < 255
|
||||
ui.previewContainer.background = if (hasTransparency) {
|
||||
checkerboardDrawable
|
||||
} else {
|
||||
AppCompatResources.getDrawable(this, R.drawable.widget_preview_checkerboard)
|
||||
}
|
||||
|
||||
// Foreground (text + icon colors)
|
||||
val fgColor = theme.foregroundColor
|
||||
val defaultTextColor = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
|
||||
val defaultIconColor = resolveWidgetThemeColor(android.R.attr.colorAccent)
|
||||
|
||||
val textViews = listOf(
|
||||
R.id.preview_left_label,
|
||||
R.id.preview_right_label,
|
||||
R.id.preview_case_label,
|
||||
R.id.preview_device_label,
|
||||
)
|
||||
val iconViews = listOf(
|
||||
R.id.preview_left_icon,
|
||||
R.id.preview_right_icon,
|
||||
R.id.preview_case_icon,
|
||||
)
|
||||
|
||||
for (id in textViews) {
|
||||
val tv = ui.previewCard.findViewById<android.widget.TextView>(id) ?: continue
|
||||
tv.setTextColor(fgColor ?: defaultTextColor)
|
||||
}
|
||||
|
||||
for (id in iconViews) {
|
||||
val iv = ui.previewCard.findViewById<ImageView>(id) ?: continue
|
||||
if (fgColor != null) {
|
||||
iv.setColorFilter(fgColor, PorterDuff.Mode.SRC_IN)
|
||||
} else {
|
||||
iv.setColorFilter(defaultIconColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
// Device label visibility
|
||||
val deviceLabel = ui.previewCard.findViewById<View>(R.id.preview_device_label)
|
||||
deviceLabel?.isVisible = theme.showDeviceLabel
|
||||
}
|
||||
|
||||
private fun createCheckerboardDrawable(): BitmapDrawable {
|
||||
val cellSize = (8 * resources.displayMetrics.density).toInt()
|
||||
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint()
|
||||
// Light squares
|
||||
paint.color = 0xFFE8E8E8.toInt()
|
||||
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
|
||||
// Dark squares
|
||||
paint.color = 0xFFD0D0D0.toInt()
|
||||
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
|
||||
canvas.drawRect(
|
||||
cellSize.toFloat(),
|
||||
cellSize.toFloat(),
|
||||
(cellSize * 2).toFloat(),
|
||||
(cellSize * 2).toFloat(),
|
||||
paint
|
||||
)
|
||||
|
||||
return bitmap.toDrawable(resources).apply {
|
||||
tileModeX = Shader.TileMode.REPEAT
|
||||
tileModeY = Shader.TileMode.REPEAT
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPresetChips() {
|
||||
val presetNames = mapOf(
|
||||
WidgetTheme.Preset.MATERIAL_YOU to getString(R.string.widget_config_preset_material_you),
|
||||
WidgetTheme.Preset.CLASSIC_DARK to getString(R.string.widget_config_preset_dark),
|
||||
WidgetTheme.Preset.CLASSIC_LIGHT to getString(R.string.widget_config_preset_light),
|
||||
WidgetTheme.Preset.BLUE to getString(R.string.widget_config_preset_blue),
|
||||
WidgetTheme.Preset.GREEN to getString(R.string.widget_config_preset_green),
|
||||
WidgetTheme.Preset.RED to getString(R.string.widget_config_preset_red),
|
||||
)
|
||||
|
||||
for (preset in WidgetTheme.Preset.entries) {
|
||||
val chip = Chip(this).apply {
|
||||
text = presetNames[preset] ?: preset.name
|
||||
isCheckable = true
|
||||
tag = preset
|
||||
setOnClickListener { vm.selectPreset(preset) }
|
||||
}
|
||||
ui.presetChipGroup.addView(chip)
|
||||
}
|
||||
|
||||
// Custom chip
|
||||
val customChip = Chip(this).apply {
|
||||
text = getString(R.string.widget_config_custom_label)
|
||||
isCheckable = true
|
||||
tag = CUSTOM_CHIP_TAG
|
||||
setOnClickListener {
|
||||
val defaultBg = resolveWidgetThemeColor(android.R.attr.colorBackground)
|
||||
val defaultFg = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
|
||||
vm.enterCustomMode(defaultBg, defaultFg)
|
||||
}
|
||||
}
|
||||
ui.presetChipGroup.addView(customChip)
|
||||
}
|
||||
|
||||
private fun updatePresetChipSelection(activePreset: WidgetTheme.Preset?) {
|
||||
for (i in 0 until ui.presetChipGroup.childCount) {
|
||||
val chip = ui.presetChipGroup.getChildAt(i) as? Chip ?: continue
|
||||
chip.isChecked = if (activePreset != null) {
|
||||
chip.tag == activePreset
|
||||
} else {
|
||||
chip.tag == CUSTOM_CHIP_TAG
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCustomSectionsVisibility(isCustomMode: Boolean) {
|
||||
val visibility = if (isCustomMode) View.VISIBLE else View.GONE
|
||||
ui.bgColorLabel.visibility = visibility
|
||||
ui.bgColorGrid.visibility = visibility
|
||||
ui.bgHexInputLayout.visibility = visibility
|
||||
ui.fgColorLabel.visibility = visibility
|
||||
ui.fgColorGrid.visibility = visibility
|
||||
ui.fgHexInputLayout.visibility = visibility
|
||||
}
|
||||
|
||||
private fun setupColorSwatches(grid: GridLayout, isBg: Boolean) {
|
||||
for (color in SWATCH_COLORS) {
|
||||
val itemView = LayoutInflater.from(this)
|
||||
.inflate(R.layout.widget_color_swatch_item, grid, false)
|
||||
|
||||
val swatchColor = itemView.findViewById<View>(R.id.swatch_color)
|
||||
val bgDrawable = swatchColor.background?.mutate() as? GradientDrawable ?: continue
|
||||
bgDrawable.setColor(color)
|
||||
swatchColor.background = bgDrawable
|
||||
|
||||
itemView.setOnClickListener {
|
||||
ui.bgHexInput.clearFocus()
|
||||
ui.fgHexInput.clearFocus()
|
||||
if (isBg) vm.setBackgroundColor(color) else vm.setForegroundColor(color)
|
||||
}
|
||||
|
||||
val params = GridLayout.LayoutParams(
|
||||
GridLayout.spec(GridLayout.UNDEFINED),
|
||||
GridLayout.spec(GridLayout.UNDEFINED, 1f),
|
||||
).apply {
|
||||
width = GridLayout.LayoutParams.WRAP_CONTENT
|
||||
height = GridLayout.LayoutParams.WRAP_CONTENT
|
||||
setGravity(Gravity.CENTER)
|
||||
}
|
||||
grid.addView(itemView, params)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSwatchSelection(grid: GridLayout, selectedColor: Int?) {
|
||||
for (i in 0 until grid.childCount) {
|
||||
val itemView = grid.getChildAt(i) as? FrameLayout ?: continue
|
||||
val color = SWATCH_COLORS.getOrNull(i) ?: continue
|
||||
val isSelected =
|
||||
selectedColor != null && (selectedColor or 0xFF000000.toInt()) == (color or 0xFF000000.toInt())
|
||||
|
||||
itemView.findViewById<View>(R.id.swatch_selected_ring)?.isVisible = isSelected
|
||||
itemView.findViewById<ImageView>(R.id.swatch_check)?.apply {
|
||||
isVisible = isSelected
|
||||
if (isSelected) {
|
||||
val checkColor = WidgetTheme.bestContrastForeground(color)
|
||||
setColorFilter(checkColor)
|
||||
setContent {
|
||||
CapodTheme {
|
||||
val state by waitForState(vm.state)
|
||||
state?.let { currentState ->
|
||||
WidgetConfigurationScreen(
|
||||
state = currentState,
|
||||
onSelectProfile = { profile -> vm.selectProfile(profile.id) },
|
||||
onSelectPreset = { preset -> vm.selectPreset(preset) },
|
||||
onEnterCustomMode = { bg, fg -> vm.enterCustomMode(bg, fg) },
|
||||
onSetBackgroundColor = { color -> vm.setBackgroundColor(color) },
|
||||
onSetForegroundColor = { color -> vm.setForegroundColor(color) },
|
||||
onSetBackgroundAlpha = { alpha -> vm.setBackgroundAlpha(alpha) },
|
||||
onSetShowDeviceLabel = { show -> vm.setShowDeviceLabel(show) },
|
||||
onReset = { vm.resetToDefaults() },
|
||||
onConfirm = {
|
||||
if (currentState.isPro) {
|
||||
confirmSelection()
|
||||
} else {
|
||||
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity)
|
||||
}
|
||||
},
|
||||
onCancel = { finish() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupHexInput() {
|
||||
val hexFilter = InputFilter { source, _, _, _, _, _ ->
|
||||
val filtered = source.toString().uppercase().filter { it in "0123456789ABCDEF" }
|
||||
if (filtered == source.toString()) null else filtered
|
||||
}
|
||||
|
||||
ui.bgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
|
||||
ui.fgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
|
||||
|
||||
ui.bgHexInput.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (isUpdatingHexFromCode) return
|
||||
val hex = s?.toString() ?: return
|
||||
if (hex.length == 6) {
|
||||
try {
|
||||
val color = "#$hex".toColorInt()
|
||||
vm.setBackgroundColor(color)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ui.fgHexInput.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (isUpdatingHexFromCode) return
|
||||
val hex = s?.toString() ?: return
|
||||
if (hex.length == 6) {
|
||||
try {
|
||||
val color = "#$hex".toColorInt()
|
||||
vm.setForegroundColor(color)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateHexInputs(theme: WidgetTheme) {
|
||||
isUpdatingHexFromCode = true
|
||||
try {
|
||||
val bgHex = theme.backgroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
|
||||
if (ui.bgHexInput.text.toString() != bgHex && !ui.bgHexInput.hasFocus()) {
|
||||
ui.bgHexInput.setText(bgHex)
|
||||
}
|
||||
|
||||
val fgHex = theme.foregroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
|
||||
if (ui.fgHexInput.text.toString() != fgHex && !ui.fgHexInput.hasFocus()) {
|
||||
ui.fgHexInput.setText(fgHex)
|
||||
}
|
||||
} finally {
|
||||
isUpdatingHexFromCode = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTransparencySlider() {
|
||||
ui.transparencySlider.addOnChangeListener { _, value, fromUser ->
|
||||
if (fromUser) {
|
||||
val alpha = 255 - (value / 100f * 255f).toInt()
|
||||
vm.setBackgroundAlpha(alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupShowDeviceLabelSwitch() {
|
||||
ui.showDeviceLabelSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
vm.setShowDeviceLabel(isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupResetButton() {
|
||||
ui.resetButton.setOnClickListener {
|
||||
vm.resetToDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmSelection() {
|
||||
vm.confirmSelection()
|
||||
|
||||
@@ -452,33 +94,5 @@ class WidgetConfigurationActivity : Activity2() {
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Widget", "ConfigurationActivity")
|
||||
private const val CUSTOM_CHIP_TAG = "custom"
|
||||
|
||||
private val SWATCH_COLORS = intArrayOf(
|
||||
0xFFF44336.toInt(), // Red
|
||||
0xFFE91E63.toInt(), // Pink
|
||||
0xFF9C27B0.toInt(), // Purple
|
||||
0xFF673AB7.toInt(), // Deep Purple
|
||||
0xFF3F51B5.toInt(), // Indigo
|
||||
0xFF2196F3.toInt(), // Blue
|
||||
0xFF03A9F4.toInt(), // Light Blue
|
||||
0xFF00BCD4.toInt(), // Cyan
|
||||
0xFF009688.toInt(), // Teal
|
||||
0xFF4CAF50.toInt(), // Green
|
||||
0xFF8BC34A.toInt(), // Light Green
|
||||
0xFFCDDC39.toInt(), // Lime
|
||||
0xFFFFEB3B.toInt(), // Yellow
|
||||
0xFFFFC107.toInt(), // Amber
|
||||
0xFFFF9800.toInt(), // Orange
|
||||
0xFFFF5722.toInt(), // Deep Orange
|
||||
0xFF795548.toInt(), // Brown
|
||||
0xFF9E9E9E.toInt(), // Grey
|
||||
0xFF607D8B.toInt(), // Blue Grey
|
||||
0xFFFFFFFF.toInt(), // White
|
||||
0xFF1E1E1E.toInt(), // Near Black
|
||||
0xFF37474F.toInt(), // Dark Blue Grey
|
||||
0xFF1B5E20.toInt(), // Dark Green
|
||||
0xFF0D47A1.toInt(), // Dark Blue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Shader
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.isVisible
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WidgetConfigurationScreen(
|
||||
state: WidgetConfigurationViewModel.State,
|
||||
onSelectProfile: (DeviceProfile) -> Unit,
|
||||
onSelectPreset: (WidgetTheme.Preset) -> Unit,
|
||||
onEnterCustomMode: (defaultBg: Int, defaultFg: Int) -> Unit,
|
||||
onSetBackgroundColor: (Int) -> Unit,
|
||||
onSetForegroundColor: (Int) -> Unit,
|
||||
onSetBackgroundAlpha: (Int) -> Unit,
|
||||
onSetShowDeviceLabel: (Boolean) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.systemBars),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 24.dp, bottom = 16.dp),
|
||||
) {
|
||||
// Profile selection header
|
||||
Text(
|
||||
text = stringResource(R.string.widget_configuration_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.widget_configuration_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Profile list
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
state.profiles.forEach { profile ->
|
||||
ProfileSelectionItem(
|
||||
profile = profile,
|
||||
isSelected = profile.id == state.selectedProfile,
|
||||
onClick = { onSelectProfile(profile) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp))
|
||||
|
||||
// Appearance section header + reset
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_appearance_label),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onReset) {
|
||||
Text(text = stringResource(R.string.widget_config_reset_label))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Live preview
|
||||
WidgetPreview(
|
||||
theme = state.theme,
|
||||
deviceLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Transparency slider
|
||||
val hasCustomBg = state.theme.backgroundColor != null
|
||||
val transparencyPercent = ((255 - state.theme.backgroundAlpha) / 255f * 100f)
|
||||
val displayPercent = (transparencyPercent / 5f).toInt() * 5
|
||||
|
||||
Text(
|
||||
text = buildString {
|
||||
append(stringResource(R.string.widget_config_transparency_label))
|
||||
if (hasCustomBg && displayPercent > 0) append(" ($displayPercent%)")
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (hasCustomBg) 1f else 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = displayPercent.toFloat(),
|
||||
onValueChange = { value ->
|
||||
val alpha = 255 - (value / 100f * 255f).toInt()
|
||||
onSetBackgroundAlpha(alpha)
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
steps = 19, // 0, 5, 10, ... 100 → 19 intermediate steps
|
||||
enabled = hasCustomBg,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
// Show device label switch
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.height(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_show_device_label),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = state.theme.showDeviceLabel,
|
||||
onCheckedChange = onSetShowDeviceLabel,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Preset chips
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_preset_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
PresetChips(
|
||||
activePreset = state.activePreset,
|
||||
isCustomMode = state.isCustomMode,
|
||||
onSelectPreset = onSelectPreset,
|
||||
onEnterCustomMode = onEnterCustomMode,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
// Custom color sections (only visible in custom mode)
|
||||
if (state.isCustomMode) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Background color
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_background_color_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ColorSwatchGrid(
|
||||
selectedColor = state.theme.backgroundColor,
|
||||
onColorSelected = onSetBackgroundColor,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
HexColorInput(
|
||||
color = state.theme.backgroundColor,
|
||||
onColorChanged = onSetBackgroundColor,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Foreground color
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_foreground_color_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ColorSwatchGrid(
|
||||
selectedColor = state.theme.foregroundColor,
|
||||
onColorSelected = onSetForegroundColor,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
HexColorInput(
|
||||
color = state.theme.foregroundColor,
|
||||
onColorChanged = onSetForegroundColor,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom bar
|
||||
HorizontalDivider()
|
||||
|
||||
if (!state.isPro) {
|
||||
Text(
|
||||
text = stringResource(R.string.common_feature_requires_pro_msg),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(onClick = onCancel) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = if (state.isPro) state.canConfirm else true,
|
||||
) {
|
||||
Text(
|
||||
text = if (state.isPro) {
|
||||
stringResource(android.R.string.ok)
|
||||
} else {
|
||||
stringResource(R.string.general_upgrade_action)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileSelectionItem(
|
||||
profile: DeviceProfile,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
OutlinedCard(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = if (isSelected) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(profile.model.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = profile.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val modelText = when (profile.model) {
|
||||
PodDevice.Model.UNKNOWN -> stringResource(R.string.pods_unknown_label)
|
||||
else -> profile.model.label
|
||||
}
|
||||
Text(
|
||||
text = modelText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetPreview(
|
||||
theme: WidgetTheme,
|
||||
deviceLabel: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val checkerboardDrawable = remember(density) {
|
||||
val cellSize = (8 * context.resources.displayMetrics.density).toInt()
|
||||
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint()
|
||||
paint.color = 0xFFE8E8E8.toInt()
|
||||
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
|
||||
paint.color = 0xFFD0D0D0.toInt()
|
||||
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
|
||||
canvas.drawRect(
|
||||
cellSize.toFloat(), cellSize.toFloat(),
|
||||
(cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint,
|
||||
)
|
||||
bitmap.toDrawable(context.resources).apply {
|
||||
tileModeX = Shader.TileMode.REPEAT
|
||||
tileModeY = Shader.TileMode.REPEAT
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedBgColor = remember(context) {
|
||||
resolveThemeColor(context, android.R.attr.colorBackground)
|
||||
}
|
||||
val resolvedTextColor = remember(context) {
|
||||
resolveThemeColor(context, android.R.attr.textColorPrimary)
|
||||
}
|
||||
val resolvedAccentColor = remember(context) {
|
||||
resolveThemeColor(context, android.R.attr.colorAccent)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val container = android.widget.FrameLayout(ctx)
|
||||
|
||||
// Outer container for checkerboard
|
||||
val outerPadding = (24 * ctx.resources.displayMetrics.density).toInt()
|
||||
container.setPadding(outerPadding, outerPadding, outerPadding, outerPadding)
|
||||
|
||||
// Clip wrapper — rounded background + clipToOutline so the inner content is clipped
|
||||
val clipWrapper = android.widget.FrameLayout(ctx).apply {
|
||||
setBackgroundResource(R.drawable.widget_preview_bg)
|
||||
clipToOutline = true
|
||||
}
|
||||
|
||||
// Inner preview content
|
||||
LayoutInflater.from(ctx).inflate(R.layout.widget_config_preview, clipWrapper, true)
|
||||
|
||||
container.addView(clipWrapper, android.widget.FrameLayout.LayoutParams(
|
||||
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.view.Gravity.CENTER,
|
||||
))
|
||||
|
||||
container
|
||||
},
|
||||
update = { container ->
|
||||
val hasTransparency = theme.backgroundColor != null && theme.backgroundAlpha < 255
|
||||
container.background = if (hasTransparency) {
|
||||
checkerboardDrawable
|
||||
} else {
|
||||
androidx.appcompat.content.res.AppCompatResources.getDrawable(
|
||||
context, R.drawable.widget_preview_checkerboard
|
||||
)
|
||||
}
|
||||
|
||||
val clipWrapper = container.getChildAt(0) ?: return@AndroidView
|
||||
val widgetRoot = clipWrapper.findViewById<View>(R.id.preview_widget_root) ?: return@AndroidView
|
||||
|
||||
// Background color applied to the inner view — clipWrapper's outline clips the corners
|
||||
val bgColor = theme.backgroundColor
|
||||
if (bgColor != null) {
|
||||
widgetRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
|
||||
} else {
|
||||
widgetRoot.setBackgroundColor(resolvedBgColor)
|
||||
}
|
||||
|
||||
// Foreground colors
|
||||
val fgColor = theme.foregroundColor
|
||||
val textColor = fgColor ?: resolvedTextColor
|
||||
val iconColor = fgColor ?: resolvedAccentColor
|
||||
|
||||
val textIds = intArrayOf(
|
||||
R.id.preview_left_label, R.id.preview_right_label,
|
||||
R.id.preview_case_label, R.id.preview_device_label,
|
||||
)
|
||||
val iconIds = intArrayOf(
|
||||
R.id.preview_left_icon, R.id.preview_right_icon, R.id.preview_case_icon,
|
||||
)
|
||||
|
||||
for (id in textIds) {
|
||||
clipWrapper.findViewById<TextView>(id)?.setTextColor(textColor)
|
||||
}
|
||||
for (id in iconIds) {
|
||||
clipWrapper.findViewById<ImageView>(id)?.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
// Device label
|
||||
val labelView = clipWrapper.findViewById<TextView>(R.id.preview_device_label)
|
||||
labelView?.isVisible = theme.showDeviceLabel
|
||||
labelView?.text = deviceLabel ?: ""
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveThemeColor(context: android.content.Context, attr: Int): Int {
|
||||
val wrapper = androidx.appcompat.view.ContextThemeWrapper(
|
||||
context,
|
||||
com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight,
|
||||
)
|
||||
val typedArray = wrapper.theme.obtainStyledAttributes(intArrayOf(attr))
|
||||
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
|
||||
typedArray.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun PresetChips(
|
||||
activePreset: WidgetTheme.Preset?,
|
||||
isCustomMode: Boolean,
|
||||
onSelectPreset: (WidgetTheme.Preset) -> Unit,
|
||||
onEnterCustomMode: (defaultBg: Int, defaultFg: Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val presetNames = mapOf(
|
||||
WidgetTheme.Preset.MATERIAL_YOU to stringResource(R.string.widget_config_preset_material_you),
|
||||
WidgetTheme.Preset.CLASSIC_DARK to stringResource(R.string.widget_config_preset_dark),
|
||||
WidgetTheme.Preset.CLASSIC_LIGHT to stringResource(R.string.widget_config_preset_light),
|
||||
WidgetTheme.Preset.BLUE to stringResource(R.string.widget_config_preset_blue),
|
||||
WidgetTheme.Preset.GREEN to stringResource(R.string.widget_config_preset_green),
|
||||
WidgetTheme.Preset.RED to stringResource(R.string.widget_config_preset_red),
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
WidgetTheme.Preset.entries.forEach { preset ->
|
||||
FilterChip(
|
||||
selected = activePreset == preset,
|
||||
onClick = { onSelectPreset(preset) },
|
||||
label = { Text(presetNames[preset] ?: preset.name) },
|
||||
)
|
||||
}
|
||||
FilterChip(
|
||||
selected = isCustomMode,
|
||||
onClick = {
|
||||
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
|
||||
val defaultFg = resolveThemeColor(context, android.R.attr.textColorPrimary)
|
||||
onEnterCustomMode(defaultBg, defaultFg)
|
||||
},
|
||||
label = { Text(stringResource(R.string.widget_config_custom_label)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ColorSwatchGrid(
|
||||
selectedColor: Int?,
|
||||
onColorSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
SWATCH_COLORS.forEach { color ->
|
||||
val isSelected = selectedColor != null &&
|
||||
(selectedColor or 0xFF000000.toInt()) == (color or 0xFF000000.toInt())
|
||||
|
||||
ColorSwatch(
|
||||
color = color,
|
||||
isSelected = isSelected,
|
||||
onClick = { onColorSelected(color) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorSwatch(
|
||||
color: Int,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Color circle
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(color))
|
||||
.border(
|
||||
width = if (isSelected) 3.dp else 1.dp,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Color(0x20000000)
|
||||
},
|
||||
shape = CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
val checkColor = WidgetTheme.bestContrastForeground(color)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(checkColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HexColorInput(
|
||||
color: Int?,
|
||||
onColorChanged: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hexString = color?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
|
||||
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue(text = hexString, selection = TextRange(hexString.length))) }
|
||||
|
||||
// Sync from external color changes (e.g. swatch clicks) only when content genuinely differs
|
||||
LaunchedEffect(hexString) {
|
||||
val currentNormalized = textFieldValue.text.uppercase().filter { it in "0123456789ABCDEF" }
|
||||
if (currentNormalized != hexString) {
|
||||
textFieldValue = TextFieldValue(text = hexString, selection = TextRange(hexString.length))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { newValue ->
|
||||
val filtered = newValue.text.uppercase().filter { it in "0123456789ABCDEF" }.take(6)
|
||||
textFieldValue = newValue.copy(text = filtered)
|
||||
if (filtered.length == 6) {
|
||||
try {
|
||||
val parsed = android.graphics.Color.parseColor("#$filtered")
|
||||
onColorChanged(parsed)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text("#") },
|
||||
singleLine = true,
|
||||
modifier = modifier.width(160.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private val SWATCH_COLORS = intArrayOf(
|
||||
0xFFF44336.toInt(), // Red
|
||||
0xFFE91E63.toInt(), // Pink
|
||||
0xFF9C27B0.toInt(), // Purple
|
||||
0xFF673AB7.toInt(), // Deep Purple
|
||||
0xFF3F51B5.toInt(), // Indigo
|
||||
0xFF2196F3.toInt(), // Blue
|
||||
0xFF03A9F4.toInt(), // Light Blue
|
||||
0xFF00BCD4.toInt(), // Cyan
|
||||
0xFF009688.toInt(), // Teal
|
||||
0xFF4CAF50.toInt(), // Green
|
||||
0xFF8BC34A.toInt(), // Light Green
|
||||
0xFFCDDC39.toInt(), // Lime
|
||||
0xFFFFEB3B.toInt(), // Yellow
|
||||
0xFFFFC107.toInt(), // Amber
|
||||
0xFFFF9800.toInt(), // Orange
|
||||
0xFFFF5722.toInt(), // Deep Orange
|
||||
0xFF795548.toInt(), // Brown
|
||||
0xFF9E9E9E.toInt(), // Grey
|
||||
0xFF607D8B.toInt(), // Blue Grey
|
||||
0xFFFFFFFF.toInt(), // White
|
||||
0xFF1E1E1E.toInt(), // Near Black
|
||||
0xFF37474F.toInt(), // Dark Blue Grey
|
||||
0xFF1B5E20.toInt(), // Dark Green
|
||||
0xFF0D47A1.toInt(), // Dark Blue
|
||||
)
|
||||
@@ -9,7 +9,8 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.combine
|
||||
import eu.darken.capod.common.uix.ViewModel3
|
||||
import eu.darken.capod.common.flow.shareLatest
|
||||
import eu.darken.capod.common.uix.ViewModel2
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
@@ -25,7 +26,7 @@ class WidgetConfigurationViewModel @Inject constructor(
|
||||
private val widgetSettings: WidgetSettings,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel3(dispatcherProvider) {
|
||||
) : ViewModel2(dispatcherProvider) {
|
||||
|
||||
private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) }
|
||||
|
||||
@@ -51,7 +52,7 @@ class WidgetConfigurationViewModel @Inject constructor(
|
||||
|
||||
private val currentTheme = MutableStateFlow(initialTheme)
|
||||
|
||||
val state = eu.darken.capod.common.flow.combine(
|
||||
val state = combine(
|
||||
selectedProfile,
|
||||
currentTheme,
|
||||
forceCustomMode,
|
||||
@@ -69,7 +70,7 @@ class WidgetConfigurationViewModel @Inject constructor(
|
||||
activePreset = activePreset,
|
||||
isCustomMode = activePreset == null,
|
||||
)
|
||||
}.asLiveData2()
|
||||
}.shareLatest(scope = vmScope)
|
||||
|
||||
data class State(
|
||||
val profiles: List<DeviceProfile> = emptyList(),
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import eu.darken.capod.common.lists.BindableVH
|
||||
import eu.darken.capod.common.lists.differ.AsyncDiffer
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.common.lists.differ.HasAsyncDiffer
|
||||
import eu.darken.capod.common.lists.differ.setupDiffer
|
||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
|
||||
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
|
||||
import javax.inject.Inject
|
||||
|
||||
class WidgetProfileSelectionAdapter @Inject constructor() :
|
||||
ModularAdapter<WidgetProfileSelectionAdapter.BaseVH<WidgetProfileSelectionAdapter.Item, ViewBinding>>(),
|
||||
HasAsyncDiffer<WidgetProfileSelectionAdapter.Item> {
|
||||
|
||||
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
|
||||
|
||||
init {
|
||||
modules.add(DataBinderMod(data))
|
||||
modules.add(TypedVHCreatorMod({ data[it] is WidgetProfileSelectionVH.Item }) { WidgetProfileSelectionVH(it) })
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = data.size
|
||||
|
||||
abstract class BaseVH<D : Item, B : ViewBinding>(
|
||||
@LayoutRes layoutId: Int,
|
||||
parent: ViewGroup
|
||||
) : VH(layoutId, parent), BindableVH<D, B>
|
||||
|
||||
interface Item : DifferItem
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.view.ViewGroup
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.lists.binding
|
||||
import eu.darken.capod.databinding.WidgetConfigurationProfileItemBinding
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
|
||||
class WidgetProfileSelectionVH(parent: ViewGroup) :
|
||||
WidgetProfileSelectionAdapter.BaseVH<WidgetProfileSelectionVH.Item, WidgetConfigurationProfileItemBinding>(
|
||||
R.layout.widget_configuration_profile_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
WidgetConfigurationProfileItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: WidgetConfigurationProfileItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> Unit = binding(payload = true) { item ->
|
||||
val profile = item.profile
|
||||
|
||||
profileName.text = profile.label
|
||||
val modelText = when (profile.model) {
|
||||
PodDevice.Model.UNKNOWN -> context.getString(R.string.pods_unknown_label)
|
||||
else -> profile.model.label
|
||||
}
|
||||
profileModel.text = modelText
|
||||
profileIcon.setImageResource(profile.model.iconRes)
|
||||
profileRadio.isChecked = item.isSelected
|
||||
root.isChecked = item.isSelected
|
||||
|
||||
root.setOnClickListener { item.onProfileClick(profile) }
|
||||
}
|
||||
|
||||
data class Item(
|
||||
val profile: DeviceProfile,
|
||||
val isSelected: Boolean,
|
||||
val onProfileClick: (DeviceProfile) -> Unit,
|
||||
) : WidgetProfileSelectionAdapter.Item {
|
||||
override val stableId: Long = profile.id.hashCode().toLong()
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package eu.darken.capod.profiles.ui
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import eu.darken.capod.common.lists.BindableVH
|
||||
import eu.darken.capod.common.lists.differ.AsyncDiffer
|
||||
import eu.darken.capod.common.lists.differ.DifferItem
|
||||
import eu.darken.capod.common.lists.differ.HasAsyncDiffer
|
||||
import eu.darken.capod.common.lists.differ.setupDiffer
|
||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
|
||||
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeviceManagerAdapter @Inject constructor() :
|
||||
ModularAdapter<DeviceManagerAdapter.BaseVH<DeviceManagerAdapter.Item, ViewBinding>>(),
|
||||
HasAsyncDiffer<DeviceManagerAdapter.Item> {
|
||||
|
||||
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
|
||||
|
||||
init {
|
||||
modules.add(DataBinderMod(data))
|
||||
modules.add(TypedVHCreatorMod({ data[it] is DeviceProfileVH.Item }) { DeviceProfileVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is NoProfilesCardVH.Item }) { NoProfilesCardVH(it) })
|
||||
modules.add(TypedVHCreatorMod({ data[it] is PriorityHintVH.Item }) { PriorityHintVH(it) })
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = data.size
|
||||
|
||||
fun moveItem(fromPosition: Int, toPosition: Int): Boolean {
|
||||
if (fromPosition < 0 || toPosition < 0 || fromPosition >= data.size || toPosition >= data.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
val currentData = data.toMutableList()
|
||||
val item = currentData.removeAt(fromPosition)
|
||||
currentData.add(toPosition, item)
|
||||
|
||||
// Update the adapter data through the differ for proper visual feedback
|
||||
asyncDiffer.submitUpdate(currentData)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
fun getItems(): List<Item> = data.toList()
|
||||
|
||||
abstract class BaseVH<D : Item, B : ViewBinding>(
|
||||
@LayoutRes layoutId: Int,
|
||||
parent: ViewGroup
|
||||
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B>
|
||||
|
||||
interface Item : DifferItem
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package eu.darken.capod.profiles.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.EdgeToEdgeHelper
|
||||
import eu.darken.capod.common.lists.differ.update
|
||||
import eu.darken.capod.common.lists.setupDefaults
|
||||
import eu.darken.capod.common.uix.Fragment3
|
||||
import eu.darken.capod.common.viewbinding.viewBinding
|
||||
import eu.darken.capod.databinding.DeviceManagerFragmentBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DeviceManagerFragment : Fragment3(R.layout.device_manager_fragment) {
|
||||
|
||||
override val vm: DeviceManagerFragmentVM by viewModels()
|
||||
override val ui: DeviceManagerFragmentBinding by viewBinding()
|
||||
|
||||
@Inject
|
||||
lateinit var adapter: DeviceManagerAdapter
|
||||
|
||||
private var isDragging = false
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
EdgeToEdgeHelper(requireActivity()).apply {
|
||||
insetsPadding(ui.root, left = true, right = true)
|
||||
insetsPadding(ui.toolbar, top = true)
|
||||
}
|
||||
|
||||
// Handle FAB margins for edge-to-edge
|
||||
ViewCompat.setOnApplyWindowInsetsListener(ui.fab) { view, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val baseMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 16f,
|
||||
resources.displayMetrics
|
||||
).toInt()
|
||||
view.updateLayoutParams<androidx.constraintlayout.widget.ConstraintLayout.LayoutParams> {
|
||||
bottomMargin = baseMargin + systemBars.bottom
|
||||
marginEnd = baseMargin + systemBars.right
|
||||
}
|
||||
insets
|
||||
}
|
||||
|
||||
ui.apply {
|
||||
list.setupDefaults(adapter, dividers = false)
|
||||
|
||||
toolbar.setNavigationOnClickListener { vm.onBackPressed() }
|
||||
|
||||
fab.setOnClickListener { vm.onAddDevice() }
|
||||
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0
|
||||
) {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val fromPosition = viewHolder.adapterPosition
|
||||
val toPosition = target.adapterPosition
|
||||
|
||||
// Only allow reordering of profile items, not empty state cards or hints
|
||||
if (adapter.data[fromPosition] is DeviceProfileVH.Item &&
|
||||
adapter.data[toPosition] is DeviceProfileVH.Item
|
||||
) {
|
||||
return adapter.moveItem(fromPosition, toPosition)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
// No swipe to dismiss
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
when (actionState) {
|
||||
ItemTouchHelper.ACTION_STATE_DRAG -> {
|
||||
isDragging = true
|
||||
}
|
||||
|
||||
ItemTouchHelper.ACTION_STATE_IDLE -> {
|
||||
if (isDragging) vm.onProfilesReordered(adapter.getItems())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = true
|
||||
override fun isItemViewSwipeEnabled(): Boolean = false
|
||||
})
|
||||
itemTouchHelper.attachToRecyclerView(list)
|
||||
}
|
||||
|
||||
vm.listItems.observe2(ui) { items ->
|
||||
adapter.update(items)
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user