diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1ae21e2e..959e43a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/eu/darken/capod/common/compose/PreviewWrapper.kt b/app/src/main/java/eu/darken/capod/common/compose/PreviewWrapper.kt new file mode 100644 index 00000000..c8edd0eb --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/compose/PreviewWrapper.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/darken/capod/common/compose/ViewModelExtensions.kt b/app/src/main/java/eu/darken/capod/common/compose/ViewModelExtensions.kt new file mode 100644 index 00000000..663eef1f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/compose/ViewModelExtensions.kt @@ -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 waitForState(flow: Flow): State { + return produceState(initialValue = null) { + flow.collect { value = it } + } +} diff --git a/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt b/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt new file mode 100644 index 00000000..f0f4d39a --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt @@ -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(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)) + } + }, + ) +} diff --git a/app/src/main/java/eu/darken/capod/common/error/ErrorEventSource2.kt b/app/src/main/java/eu/darken/capod/common/error/ErrorEventSource2.kt new file mode 100644 index 00000000..c1d818a9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/error/ErrorEventSource2.kt @@ -0,0 +1,7 @@ +package eu.darken.capod.common.error + +import eu.darken.capod.common.flow.SingleEventFlow + +interface ErrorEventSource2 { + val errorEvents: SingleEventFlow +} diff --git a/app/src/main/java/eu/darken/capod/common/flow/SingleEventFlow.kt b/app/src/main/java/eu/darken/capod/common/flow/SingleEventFlow.kt new file mode 100644 index 00000000..46f255d4 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/flow/SingleEventFlow.kt @@ -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 : AbstractFlow() { + private val channel = Channel(Channel.Factory.BUFFERED) + + override suspend fun collectSafely(collector: FlowCollector) = channel.receiveAsFlow().collect(collector) + + suspend fun emit(value: T) = channel.send(value) + + fun tryEmit(value: T): ChannelResult = channel.trySend(value) + + fun emitBlocking(value: T): ChannelResult = channel.trySendBlocking(value) +} diff --git a/app/src/main/java/eu/darken/capod/common/navigation/FragmentExtensions.kt b/app/src/main/java/eu/darken/capod/common/navigation/FragmentExtensions.kt deleted file mode 100644 index 9f2bc230..00000000 --- a/app/src/main/java/eu/darken/capod/common/navigation/FragmentExtensions.kt +++ /dev/null @@ -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 issue-142847973 - */ -@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) -} diff --git a/app/src/main/java/eu/darken/capod/common/navigation/LocalNavigationController.kt b/app/src/main/java/eu/darken/capod/common/navigation/LocalNavigationController.kt new file mode 100644 index 00000000..a980c72a --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/LocalNavigationController.kt @@ -0,0 +1,5 @@ +package eu.darken.capod.common.navigation + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalNavigationController = staticCompositionLocalOf { null } diff --git a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt new file mode 100644 index 00000000..05be5630 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavControllerExtensions.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavControllerExtensions.kt deleted file mode 100644 index a3b5a9d0..00000000 --- a/app/src/main/java/eu/darken/capod/common/navigation/NavControllerExtensions.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavDestinationExtensions.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavDestinationExtensions.kt deleted file mode 100644 index bd3a4d65..00000000 --- a/app/src/main/java/eu/darken/capod/common/navigation/NavDestinationExtensions.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavEvent.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavEvent.kt new file mode 100644 index 00000000..3bf8355a --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/NavEvent.kt @@ -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 +} diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavigationController.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavigationController.kt new file mode 100644 index 00000000..0b74648d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/NavigationController.kt @@ -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? = null + + private val backStack: NavBackStack + get() = _backStack ?: error("NavigationController not initialized") + + fun setup(backStack: NavBackStack) { + 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") + } +} diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavigationDestination.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavigationDestination.kt new file mode 100644 index 00000000..4b3f0330 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/NavigationDestination.kt @@ -0,0 +1,5 @@ +package eu.darken.capod.common.navigation + +import androidx.navigation3.runtime.NavKey + +interface NavigationDestination : NavKey, java.io.Serializable diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavigationEntry.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavigationEntry.kt new file mode 100644 index 00000000..c78b07a4 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/NavigationEntry.kt @@ -0,0 +1,8 @@ +package eu.darken.capod.common.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey + +interface NavigationEntry { + fun EntryProviderScope.setup() +} diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavigationEventHandler.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavigationEventHandler.kt new file mode 100644 index 00000000..9018f84e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/NavigationEventHandler.kt @@ -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") diff --git a/app/src/main/java/eu/darken/capod/common/navigation/NavigationEventSource.kt b/app/src/main/java/eu/darken/capod/common/navigation/NavigationEventSource.kt new file mode 100644 index 00000000..7eaab705 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/navigation/NavigationEventSource.kt @@ -0,0 +1,7 @@ +package eu.darken.capod.common.navigation + +import eu.darken.capod.common.flow.SingleEventFlow + +interface NavigationEventSource { + val navEvents: SingleEventFlow +} diff --git a/app/src/main/java/eu/darken/capod/common/preferences/IntentPreference.kt b/app/src/main/java/eu/darken/capod/common/preferences/IntentPreference.kt deleted file mode 100644 index 2591120d..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/IntentPreference.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/preferences/MaterialSwitchPreference.kt b/app/src/main/java/eu/darken/capod/common/preferences/MaterialSwitchPreference.kt deleted file mode 100644 index e6368fa6..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/MaterialSwitchPreference.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/preferences/PercentSliderPreference.kt b/app/src/main/java/eu/darken/capod/common/preferences/PercentSliderPreference.kt deleted file mode 100644 index a103796a..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/PercentSliderPreference.kt +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/preferences/PercentSliderPreferenceDialogFragment.kt b/app/src/main/java/eu/darken/capod/common/preferences/PercentSliderPreferenceDialogFragment.kt deleted file mode 100644 index 96aead1b..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/PercentSliderPreferenceDialogFragment.kt +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsBaseItem.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsBaseItem.kt new file mode 100644 index 00000000..0f3c6b2e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsBaseItem.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsCategoryHeader.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsCategoryHeader.kt new file mode 100644 index 00000000..42d6b84b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsCategoryHeader.kt @@ -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) + ) +} diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsDivider.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsDivider.kt new file mode 100644 index 00000000..81f0217d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsDivider.kt @@ -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) + ) +} diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsListPreferenceItem.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsListPreferenceItem.kt new file mode 100644 index 00000000..5880f623 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsListPreferenceItem.kt @@ -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 SettingsListPreferenceItem( + icon: ImageVector, + title: String, + entries: List, + 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)) + } + }, + ) + } +} diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsPreferenceItem.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsPreferenceItem.kt new file mode 100644 index 00000000..28f7caf1 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsPreferenceItem.kt @@ -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 + ) +} diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsSliderItem.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsSliderItem.kt new file mode 100644 index 00000000..2df1bdac --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsSliderItem.kt @@ -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 = 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), + ) + } +} diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsSwitchItem.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsSwitchItem.kt new file mode 100644 index 00000000..fdcc0e01 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsSwitchItem.kt @@ -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) + ) + } + ) +} diff --git a/app/src/main/java/eu/darken/capod/common/theming/CapodTheme.kt b/app/src/main/java/eu/darken/capod/common/theming/CapodTheme.kt new file mode 100644 index 00000000..1770cbef --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/theming/CapodTheme.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/common/uix/BottomSheetDialogFragment2.kt b/app/src/main/java/eu/darken/capod/common/uix/BottomSheetDialogFragment2.kt deleted file mode 100644 index 4017b150..00000000 --- a/app/src/main/java/eu/darken/capod/common/uix/BottomSheetDialogFragment2.kt +++ /dev/null @@ -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 LiveData.observe2( - ui: VB, - crossinline callback: VB.(T) -> Unit - ) { - observe(viewLifecycleOwner) { callback.invoke(ui, it) } - } -} diff --git a/app/src/main/java/eu/darken/capod/common/uix/Fragment2.kt b/app/src/main/java/eu/darken/capod/common/uix/Fragment2.kt deleted file mode 100644 index bb4f8f3b..00000000 --- a/app/src/main/java/eu/darken/capod/common/uix/Fragment2.kt +++ /dev/null @@ -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) - } - -} diff --git a/app/src/main/java/eu/darken/capod/common/uix/Fragment3.kt b/app/src/main/java/eu/darken/capod/common/uix/Fragment3.kt deleted file mode 100644 index 63b3e757..00000000 --- a/app/src/main/java/eu/darken/capod/common/uix/Fragment3.kt +++ /dev/null @@ -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 LiveData.observe2( - crossinline callback: (T) -> Unit - ) { - observe(viewLifecycleOwner) { callback.invoke(it) } - } - - inline fun LiveData.observe2( - ui: VB, - crossinline callback: VB.(T) -> Unit - ) { - observe(viewLifecycleOwner) { callback.invoke(ui, it) } - } - -} diff --git a/app/src/main/java/eu/darken/capod/common/uix/PreferenceFragment2.kt b/app/src/main/java/eu/darken/capod/common/uix/PreferenceFragment2.kt deleted file mode 100644 index 0ee8172f..00000000 --- a/app/src/main/java/eu/darken/capod/common/uix/PreferenceFragment2.kt +++ /dev/null @@ -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 - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/uix/PreferenceFragment3.kt b/app/src/main/java/eu/darken/capod/common/uix/PreferenceFragment3.kt deleted file mode 100644 index 72e99556..00000000 --- a/app/src/main/java/eu/darken/capod/common/uix/PreferenceFragment3.kt +++ /dev/null @@ -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 LiveData.observe2( - crossinline callback: (T) -> Unit - ) { - observe(viewLifecycleOwner) { callback.invoke(it) } - } - - inline fun LiveData.observe2( - ui: VB, - crossinline callback: VB.(T) -> Unit - ) { - observe(viewLifecycleOwner) { callback.invoke(ui, it) } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/uix/ViewModel4.kt b/app/src/main/java/eu/darken/capod/common/uix/ViewModel4.kt new file mode 100644 index 00000000..e43071d3 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/uix/ViewModel4.kt @@ -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() + override val errorEvents = SingleEventFlow() + + init { + launchErrorHandler = CoroutineExceptionHandler { _, ex -> + log(TAG) { "Error during launch: ${ex.asLog()}" } + errorEvents.emitBlocking(ex) + } + } + + override fun Flow.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) + } +} diff --git a/app/src/main/java/eu/darken/capod/common/uix/ViewModelLazyKeyed.kt b/app/src/main/java/eu/darken/capod/common/uix/ViewModelLazyKeyed.kt deleted file mode 100644 index 1a143a91..00000000 --- a/app/src/main/java/eu/darken/capod/common/uix/ViewModelLazyKeyed.kt +++ /dev/null @@ -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 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( - private val viewModelClass: KClass, - private val keyProducer: (() -> String)? = null, - private val storeProducer: () -> ViewModelStore, - private val factoryProducer: () -> ViewModelProvider.Factory -) : Lazy { - 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 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 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 Fragment.createViewModelLazyKeyed( - viewModelClass: KClass, - keyProducer: (() -> String)? = null, - storeProducer: () -> ViewModelStore, - factoryProducer: (() -> ViewModelProvider.Factory)? = null -): Lazy { - 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 ComponentActivity.viewModelsKeyed( - noinline keyProducer: (() -> String)? = null, - noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null -): Lazy { - 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) -} diff --git a/app/src/main/java/eu/darken/capod/common/viewbinding/ViewBindingExtensions.kt b/app/src/main/java/eu/darken/capod/common/viewbinding/ViewBindingExtensions.kt deleted file mode 100644 index 587749a1..00000000 --- a/app/src/main/java/eu/darken/capod/common/viewbinding/ViewBindingExtensions.kt +++ /dev/null @@ -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.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.viewBinding( - bindingProvider: FragmentT.() -> BindingT, - lifecycleOwnerProvider: FragmentT.() -> LifecycleOwner -) = ViewBindingProperty(bindingProvider, lifecycleOwnerProvider) - -class ViewBindingProperty( - private val bindingProvider: (ComponentT) -> BindingT, - private val lifecycleOwnerProvider: ComponentT.() -> LifecycleOwner -) : ReadOnlyProperty { - - 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) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt index 606d0a3a..84a154fa 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt @@ -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") } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/MainActivityVM.kt b/app/src/main/java/eu/darken/capod/main/ui/MainActivityVM.kt deleted file mode 100644 index 9ad29118..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/MainActivityVM.kt +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingFragment.kt deleted file mode 100644 index e039e347..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingFragment.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingFragmentVM.kt deleted file mode 100644 index b6eedaf8..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingFragmentVM.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingNavigation.kt new file mode 100644 index 00000000..30815591 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingNavigation.kt @@ -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.setup() { + entry { OnboardingScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: OnboardingNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..adaadbcd --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..edcb3e50 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingViewModel.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewAdapter.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewAdapter.kt deleted file mode 100644 index 2620bb77..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewAdapter.kt +++ /dev/null @@ -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>(), - HasAsyncDiffer { - - 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( - @LayoutRes layoutId: Int, - parent: ViewGroup - ) : VH(layoutId, parent), BindableVH - - interface Item : DifferItem - -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt deleted file mode 100644 index e59191f4..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt +++ /dev/null @@ -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 - 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) - } - } -} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt deleted file mode 100644 index 03eb6a42..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt +++ /dev/null @@ -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 { - while (isActive) { - trySend(Unit) - delay(3000) - } - } - - val workerAutolaunch: LiveData = 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() - - private var showUnmatchedDevices = false - - private val pods: Flow> = permissionTool.missingPermissions - .flatMapLatest { permissions -> - if (permissions.isNotEmpty()) { - return@flatMapLatest flowOf(emptyList()) - } - - podMonitor.devices - } - .catch { errorEvents.postValue(it) } - .throttleLatest(1000) - - val listItems: LiveData> = combine( - updateTicker, - permissionTool.missingPermissions, - pods, - debugSettings.isDebugModeEnabled.flow, - bluetoothManager.isBluetoothEnabled, - profilesRepo.profiles, - ) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles -> - val items = mutableListOf() - - 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") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewNavigation.kt new file mode 100644 index 00000000..6926b9d9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewNavigation.kt @@ -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.setup() { + entry { OverviewScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: OverviewNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt new file mode 100644 index 00000000..38c6697b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt new file mode 100644 index 00000000..45eb415d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -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() + 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 { + 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, + val devices: List, + val isDebugMode: Boolean, + val isBluetoothEnabled: Boolean, + val profiles: List, + val upgradeInfo: UpgradeRepo.Info, + val showUnmatchedDevices: Boolean, + ) { + val profiledDevices: List get() = devices.filter { it.meta.profile != null } + val unmatchedDevices: List 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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BluetoothDisabledCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BluetoothDisabledCard.kt new file mode 100644 index 00000000..79783ffd --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BluetoothDisabledCard.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BluetoothDisabledVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BluetoothDisabledVH.kt deleted file mode 100644 index 0c0d7a39..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BluetoothDisabledVH.kt +++ /dev/null @@ -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( - R.layout.overview_bluetooth_disabled_item, - parent - ) { - - override val viewBinding = lazy { - OverviewBluetoothDisabledItemBinding.bind(itemView) - } - - override val onBindData: OverviewBluetoothDisabledItemBinding.( - item: Item, - payloads: List - ) -> 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 } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt new file mode 100644 index 00000000..9ca01d20 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -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, + ) + } + + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitoringActiveCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitoringActiveCard.kt new file mode 100644 index 00000000..a5425713 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitoringActiveCard.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitoringActiveVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitoringActiveVH.kt deleted file mode 100644 index 3f9b0119..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitoringActiveVH.kt +++ /dev/null @@ -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( - R.layout.overview_monitoring_active_item, - parent - ) { - - override val viewBinding = lazy { - OverviewMonitoringActiveItemBinding.bind(itemView) - } - - override val onBindData: OverviewMonitoringActiveItemBinding.( - item: Item, - payloads: List - ) -> 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 } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/NoProfilesCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/NoProfilesCard.kt new file mode 100644 index 00000000..12bf8ffb --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/NoProfilesCard.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/NoProfilesVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/NoProfilesVH.kt deleted file mode 100644 index b9ad3cdc..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/NoProfilesVH.kt +++ /dev/null @@ -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( - R.layout.overview_noprofiles_item, - parent - ) { - - override val viewBinding = lazy { - OverviewNoprofilesItemBinding.bind(itemView) - } - - override val onBindData: OverviewNoprofilesItemBinding.( - item: Item, - payloads: List - ) -> 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 } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt new file mode 100644 index 00000000..06f6cbfd --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCardVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCardVH.kt deleted file mode 100644 index b0c708c6..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCardVH.kt +++ /dev/null @@ -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( - R.layout.overview_permission_item, - parent - ) { - - override val viewBinding = lazy { - OverviewPermissionItemBinding.bind(itemView) - } - - override val onBindData: OverviewPermissionItemBinding.( - item: Item, - payloads: List - ) -> 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("$ppText", 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 } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt new file mode 100644 index 00000000..e1b4679d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt new file mode 100644 index 00000000..f1d0edb8 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnmatchedDevicesCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnmatchedDevicesCard.kt new file mode 100644 index 00000000..99789722 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnmatchedDevicesCard.kt @@ -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) + } + ) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnmatchedDevicesCardVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnmatchedDevicesCardVH.kt deleted file mode 100644 index dbeafed5..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnmatchedDevicesCardVH.kt +++ /dev/null @@ -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( - R.layout.overview_unmatched_devices_item, - parent - ) { - - override val viewBinding = lazy { - OverviewUnmatchedDevicesItemBinding.bind(itemView) - } - - override val onBindData: OverviewUnmatchedDevicesItemBinding.( - item: Item, - payloads: List - ) -> 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 } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/DualPodsCardVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/DualPodsCardVH.kt deleted file mode 100644 index 48e0a119..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/DualPodsCardVH.kt +++ /dev/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( - 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 -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/PodDeviceVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/PodDeviceVH.kt deleted file mode 100644 index e75abc87..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/PodDeviceVH.kt +++ /dev/null @@ -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( - @LayoutRes layoutId: Int, - parent: ViewGroup -) : ModularAdapter.VH(layoutId, parent), BindableVH { - - 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 - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/SinglePodsCardVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/SinglePodsCardVH.kt deleted file mode 100644 index 10b8682b..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/SinglePodsCardVH.kt +++ /dev/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( - 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 -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt deleted file mode 100644 index 5532654e..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt +++ /dev/null @@ -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( - 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 -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsFragment.kt deleted file mode 100644 index 7feece76..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsFragment.kt +++ /dev/null @@ -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() - - @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(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" - } -} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsFragmentVM.kt deleted file mode 100644 index dfc68acc..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsFragmentVM.kt +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsIndexFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsIndexFragment.kt deleted file mode 100644 index 59b8ecf0..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsIndexFragment.kt +++ /dev/null @@ -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("core.changelog")!!.summary = BuildConfigWrap.VERSION_DESCRIPTION - findPreference("core.privacy")!!.setOnPreferenceClickListener { - webpageTool.open(PrivacyPolicy.URL) - true - } - findPreference("core.profile.manager")!!.setOnPreferenceClickListener { - findNavController().navigate(MainDirections.actionGlobalDeviceManagerFragment()) - true - } - - super.onPreferencesCreated() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsNavigation.kt new file mode 100644 index 00000000..ad6bf06f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsNavigation.kt @@ -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.setup() { + entry { SettingsScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: SettingsNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt new file mode 100644 index 00000000..f46e4c3f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsViewModel.kt new file mode 100644 index 00000000..339ec959 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsViewModel.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsFragment.kt deleted file mode 100644 index f1c5b30a..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsFragment.kt +++ /dev/null @@ -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 } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsFragmentVM.kt deleted file mode 100644 index a79451bc..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsFragmentVM.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsNavigation.kt new file mode 100644 index 00000000..f7aa5909 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsNavigation.kt @@ -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.setup() { + entry { AcknowledgementsScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: AcknowledgementsNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt new file mode 100644 index 00000000..4adf3a58 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt @@ -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") }, + ) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsViewModel.kt new file mode 100644 index 00000000..a81c2a39 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsViewModel.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt deleted file mode 100644 index b28deb01..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt +++ /dev/null @@ -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(generalSettings.monitorMode.key)!! } - private val scanModePref by lazy { findPreference(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) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragmentVM.kt deleted file mode 100644 index 4a6d0f25..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragmentVM.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsNavigation.kt new file mode 100644 index 00000000..c3cde72f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsNavigation.kt @@ -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.setup() { + entry { GeneralSettingsScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: GeneralSettingsNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt new file mode 100644 index 00000000..29c92910 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt @@ -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 ListPreferenceDialog( + title: String, + entries: List, + 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)) + } + }, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt new file mode 100644 index 00000000..afd7c14e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsFragment.kt deleted file mode 100644 index 15f267d3..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsFragment.kt +++ /dev/null @@ -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 - -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsFragmentVM.kt deleted file mode 100644 index f439616c..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsFragmentVM.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsNavigation.kt new file mode 100644 index 00000000..15600a72 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsNavigation.kt @@ -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.setup() { + entry { DebugSettingsScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsScreen.kt new file mode 100644 index 00000000..f568e23e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsScreen.kt @@ -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), + ) + }, + ) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsViewModel.kt new file mode 100644 index 00000000..3351601e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsViewModel.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragment.kt deleted file mode 100644 index e1a8d362..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragment.kt +++ /dev/null @@ -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("support.debuglog")!! } - private val troubleshooterPref by lazy { findPreference("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) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragmentVM.kt deleted file mode 100644 index 9ada8870..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragmentVM.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportNavigation.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportNavigation.kt new file mode 100644 index 00000000..c031c99b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportNavigation.kt @@ -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.setup() { + entry { SupportScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: SupportNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt new file mode 100644 index 00000000..54606d18 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt new file mode 100644 index 00000000..8916354d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportViewModel.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt index e45604cb..4c582a19 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt @@ -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(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(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(id) ?: continue - tv.setTextColor(fgColor ?: defaultTextColor) - } - - for (id in iconViews) { - val iv = ui.previewCard.findViewById(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(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(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(R.id.swatch_selected_ring)?.isVisible = isSelected - itemView.findViewById(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 - ) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt new file mode 100644 index 00000000..5663a820 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt @@ -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(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(id)?.setTextColor(textColor) + } + for (id in iconIds) { + clipWrapper.findViewById(id)?.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + } + + // Device label + val labelView = clipWrapper.findViewById(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 +) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt index 4fd29bab..ebc4e6bc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt @@ -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 = emptyList(), diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionAdapter.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionAdapter.kt deleted file mode 100644 index 61f04056..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionAdapter.kt +++ /dev/null @@ -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>(), - HasAsyncDiffer { - - 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( - @LayoutRes layoutId: Int, - parent: ViewGroup - ) : VH(layoutId, parent), BindableVH - - interface Item : DifferItem -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionVH.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionVH.kt deleted file mode 100644 index 07b1b1c2..00000000 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionVH.kt +++ /dev/null @@ -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( - R.layout.widget_configuration_profile_item, - parent - ) { - - override val viewBinding = lazy { - WidgetConfigurationProfileItemBinding.bind(itemView) - } - - override val onBindData: WidgetConfigurationProfileItemBinding.( - item: Item, - payloads: List - ) -> 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() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerAdapter.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerAdapter.kt deleted file mode 100644 index ddca15f7..00000000 --- a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerAdapter.kt +++ /dev/null @@ -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>(), - HasAsyncDiffer { - - 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 = data.toList() - - abstract class BaseVH( - @LayoutRes layoutId: Int, - parent: ViewGroup - ) : ModularAdapter.VH(layoutId, parent), BindableVH - - interface Item : DifferItem -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerFragment.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerFragment.kt deleted file mode 100644 index 2d4eb8d5..00000000 --- a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerFragment.kt +++ /dev/null @@ -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 { - 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerFragmentVM.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerFragmentVM.kt deleted file mode 100644 index 2ff4a035..00000000 --- a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerFragmentVM.kt +++ /dev/null @@ -1,84 +0,0 @@ -package eu.darken.capod.profiles.ui - -import androidx.lifecycle.LiveData -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.asLiveData -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.logging.logTag -import eu.darken.capod.common.uix.ViewModel3 -import eu.darken.capod.profiles.core.DeviceProfile -import eu.darken.capod.profiles.core.DeviceProfilesRepo -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -@HiltViewModel -class DeviceManagerFragmentVM @Inject constructor( - @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, - dispatcherProvider: DispatcherProvider, - private val deviceProfilesRepo: DeviceProfilesRepo, -) : ViewModel3(dispatcherProvider = dispatcherProvider) { - - val listItems: LiveData> = deviceProfilesRepo.profiles - .map { profiles -> - if (profiles.isEmpty()) { - listOf( - NoProfilesCardVH.Item( - onAddProfile = { onAddDevice() } - ) - ) - } else { - val profileItems = profiles.map { profile -> - DeviceProfileVH.Item( - profile = profile, - onItemClick = { onEditProfile(it) } - ) - } - - // Add priority hint at the end if there are 2+ profiles - if (profiles.size >= 2) { - profileItems + PriorityHintVH.Item() - } else { - profileItems - } - } - } - .onEach { log(TAG) { "Profiles updated: ${it.size} items" } } - .asLiveData() - - fun onAddDevice() { - log(TAG) { "onAddDevice()" } - DeviceManagerFragmentDirections - .actionDeviceManagerFragmentToDeviceProfileCreationFragment() - .navigate() - } - - fun onBackPressed() { - log(TAG) { "onBackPressed()" } - navEvents.postValue(null) - } - - private fun onEditProfile(profile: DeviceProfile) { - log(TAG) { "onEditProfile(): $profile" } - DeviceManagerFragmentDirections - .actionDeviceManagerFragmentToDeviceProfileCreationFragment(profileId = profile.id) - .navigate() - } - - fun onProfilesReordered(items: List) { - log(TAG) { "onProfilesReordered(): ${items.size} items" } - val profiles = items.filterIsInstance().map { it.profile } - if (profiles.isNotEmpty()) { - launch { - deviceProfilesRepo.reorderProfiles(profiles) - log(TAG) { "Profiles reordered: ${profiles.map { it.label }}" } - } - } - } - - companion object { - private val TAG = logTag("DeviceManager", "ViewModel") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerNavigation.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerNavigation.kt new file mode 100644 index 00000000..6a5944bc --- /dev/null +++ b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerNavigation.kt @@ -0,0 +1,26 @@ +package eu.darken.capod.profiles.ui + +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 DeviceManagerNavigation @Inject constructor() : NavigationEntry { + override fun EntryProviderScope.setup() { + entry { DeviceManagerScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: DeviceManagerNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt new file mode 100644 index 00000000..9eb2a436 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt @@ -0,0 +1,205 @@ +package eu.darken.capod.profiles.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +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.TextButton +import androidx.compose.material3.TopAppBar +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 +import eu.darken.capod.profiles.core.DeviceProfile + +@Composable +fun DeviceManagerScreenHost(vm: DeviceManagerViewModel = hiltViewModel()) { + ErrorEventHandler(vm) + NavigationEventHandler(vm) + + val state by waitForState(vm.state) + state?.let { + DeviceManagerScreen( + state = it, + onBack = { vm.navUp() }, + onAddDevice = { vm.onAddDevice() }, + onEditProfile = { profile -> vm.onEditProfile(profile) }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceManagerScreen( + state: DeviceManagerViewModel.State, + onBack: () -> Unit, + onAddDevice: () -> Unit, + onEditProfile: (DeviceProfile) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.settings_devices_label)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAddDevice) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.profiles_add_action), + ) + } + }, + ) { innerPadding -> + if (state.profiles.isEmpty()) { + EmptyState( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onAddDevice = onAddDevice, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + items( + items = state.profiles, + key = { it.id }, + ) { profile -> + ProfileRow( + profile = profile, + onClick = { onEditProfile(profile) }, + ) + } + + if (state.profiles.size >= 2) { + item { + PriorityHint() + } + } + } + } + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier, + onAddDevice: () -> Unit, +) { + Column( + modifier = modifier.padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.profiles_empty_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.profiles_empty_description), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = onAddDevice) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.profiles_add_action)) + } + } + } + } +} + +@Composable +private fun ProfileRow( + profile: DeviceProfile, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(profile.model.iconRes), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = profile.model.label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun PriorityHint() { + Text( + text = stringResource(R.string.profiles_priority_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) +} diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerViewModel.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerViewModel.kt new file mode 100644 index 00000000..ab4b9cb7 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerViewModel.kt @@ -0,0 +1,47 @@ +package eu.darken.capod.profiles.ui + +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.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.profiles.core.DeviceProfile +import eu.darken.capod.profiles.core.DeviceProfilesRepo +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class DeviceManagerViewModel @Inject constructor( + dispatcherProvider: DispatcherProvider, + private val deviceProfilesRepo: DeviceProfilesRepo, +) : ViewModel4(dispatcherProvider) { + + val state = deviceProfilesRepo.profiles.map { profiles -> + State(profiles = profiles) + }.shareLatest(scope = vmScope) + + data class State( + val profiles: List, + ) + + fun onAddDevice() { + log(TAG) { "onAddDevice()" } + navTo(Nav.Main.DeviceProfileCreation()) + } + + fun onEditProfile(profile: DeviceProfile) { + log(TAG) { "onEditProfile(): $profile" } + navTo(Nav.Main.DeviceProfileCreation(profileId = profile.id)) + } + + fun onReorder(profiles: List) = launch { + log(TAG) { "onReorder(): ${profiles.size} profiles" } + deviceProfilesRepo.reorderProfiles(profiles) + } + + companion object { + private val TAG = logTag("DeviceManager", "ViewModel") + } +} diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceProfileVH.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceProfileVH.kt deleted file mode 100644 index d6fdc2ab..00000000 --- a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceProfileVH.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eu.darken.capod.profiles.ui - -import android.view.ViewGroup -import eu.darken.capod.R -import eu.darken.capod.databinding.DeviceManagerItemBinding -import eu.darken.capod.profiles.core.DeviceProfile - -class DeviceProfileVH(parent: ViewGroup) : - DeviceManagerAdapter.BaseVH( - R.layout.device_manager_item, - parent - ) { - - override val viewBinding = lazy { DeviceManagerItemBinding.bind(itemView) } - - override val onBindData: DeviceManagerItemBinding.( - item: Item, - payloads: List - ) -> Unit = { item, _ -> - val profile = item.profile - - deviceIcon.setImageResource(profile.model.iconRes) - deviceName.text = profile.label - deviceDetails.text = buildString { - append(profile.model.name) - profile.address?.let { - append(" • ") - append(it) - } - } - - itemView.setOnClickListener { item.onItemClick(profile) } - } - - data class Item( - val profile: DeviceProfile, - val onItemClick: (DeviceProfile) -> Unit, - ) : DeviceManagerAdapter.Item { - override val stableId: Long = profile.id.hashCode().toLong() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/NoProfilesCardVH.kt b/app/src/main/java/eu/darken/capod/profiles/ui/NoProfilesCardVH.kt deleted file mode 100644 index aefef5a9..00000000 --- a/app/src/main/java/eu/darken/capod/profiles/ui/NoProfilesCardVH.kt +++ /dev/null @@ -1,34 +0,0 @@ -package eu.darken.capod.profiles.ui - -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.DeviceManagerNoprofilesItemBinding - -class NoProfilesCardVH(parent: ViewGroup) : - DeviceManagerAdapter.BaseVH( - R.layout.device_manager_noprofiles_item, - parent - ) { - - override val viewBinding = lazy { - DeviceManagerNoprofilesItemBinding.bind(itemView) - } - - override val onBindData: DeviceManagerNoprofilesItemBinding.( - item: Item, - payloads: List - ) -> Unit = binding(payload = true) { item -> - addProfileAction.setOnClickListener { item.onAddProfile() } - } - - data class Item( - val onAddProfile: () -> Unit, - ) : DeviceManagerAdapter.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 } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/PriorityHintVH.kt b/app/src/main/java/eu/darken/capod/profiles/ui/PriorityHintVH.kt deleted file mode 100644 index 22d9341e..00000000 --- a/app/src/main/java/eu/darken/capod/profiles/ui/PriorityHintVH.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eu.darken.capod.profiles.ui - -import android.view.ViewGroup -import eu.darken.capod.R -import eu.darken.capod.common.lists.BindableVH -import eu.darken.capod.databinding.DeviceManagerPriorityHintItemBinding - -class PriorityHintVH(parent: ViewGroup) : - DeviceManagerAdapter.BaseVH( - R.layout.device_manager_priority_hint_item, - parent - ) { - - override val viewBinding = lazy { DeviceManagerPriorityHintItemBinding.bind(itemView) } - - override val onBindData: DeviceManagerPriorityHintItemBinding.( - item: Item, - payloads: List - ) -> Unit = { _, _ -> - // Nothing to bind - the layout already shows the static hint text - } - - data class Item( - val dummy: Unit = Unit - ) : DeviceManagerAdapter.Item { - override val stableId: Long = this.javaClass.hashCode().toLong() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationFragment.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationFragment.kt deleted file mode 100644 index 2f6338eb..00000000 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationFragment.kt +++ /dev/null @@ -1,341 +0,0 @@ -package eu.darken.capod.profiles.ui.creation - -import android.content.Context -import android.os.Bundle -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.viewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.slider.Slider -import dagger.hilt.android.AndroidEntryPoint -import eu.darken.capod.R -import eu.darken.capod.common.EdgeToEdgeHelper -import eu.darken.capod.common.WebpageTool -import eu.darken.capod.common.bluetooth.BluetoothDevice2 -import eu.darken.capod.common.fromHex -import eu.darken.capod.common.toHex -import eu.darken.capod.common.uix.Fragment3 -import eu.darken.capod.common.viewbinding.viewBinding -import eu.darken.capod.databinding.DeviceProfileCreationFragmentBinding -import eu.darken.capod.pods.core.PodDevice -import javax.inject.Inject - -@AndroidEntryPoint -class DeviceProfileCreationFragment : Fragment3(R.layout.device_profile_creation_fragment) { - - override val vm: DeviceProfileCreationFragmentVM by viewModels() - override val ui: DeviceProfileCreationFragmentBinding by viewBinding() - - @Inject lateinit var webpageTool: WebpageTool - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - EdgeToEdgeHelper(requireActivity()).apply { - insetsPadding(ui.root, left = true, right = true) - insetsPadding(ui.toolbar, top = true) - } - - // Handle bottom padding for NestedScrollView to avoid navigation bar overlap - ViewCompat.setOnApplyWindowInsetsListener(ui.scrollView) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - val basePadding = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 16f, - resources.displayMetrics - ).toInt() - view.setPadding( - view.paddingLeft, - view.paddingTop, - view.paddingRight, - basePadding + systemBars.bottom - ) - insets - } - - ui.apply { - toolbar.setNavigationOnClickListener { - if (vm.hasUnsavedChanges()) { - showUnsavedChangesConfirmation() - } else { - vm.onBackPressed() - } - } - toolbar.setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.action_save -> { - vm.saveProfile() - true - } - R.id.action_delete -> { - showDeleteConfirmation() - true - } - else -> false - } - } - - // Show delete button only in edit mode - toolbar.menu.findItem(R.id.action_delete)?.isVisible = vm.isEditMode - - nameInput.doOnTextChanged { text, _, _, _ -> - vm.updateName(text?.toString() ?: "") - } - - signalQualitySlider.addOnChangeListener { _: Slider, value: Float, _: Boolean -> - vm.updateMinimumSignalQuality(value / 100f) - } - - // Key validation regex - matches dialog format: "FE-D0-1C-54-11-81-BC-BC-87-D2-C4-3F-31-64-5F-EE" - val keyRegex = Regex("^([0-9A-F]{2}[- ]){15}[0-9A-F]{2}\$") - val exampleKey = "FE-D0-1C-54-11-81-BC-BC-87-D2-C4-3F-31-64-5F-EE" - - identityKeyInput.doOnTextChanged { text, _, _, _ -> - val keyText = text?.toString() ?: "" - when { - keyText.isEmpty() -> { - identityKeyInputLayout.error = null - vm.updateIdentityKey(null) - } - keyText.matches(keyRegex) -> { - identityKeyInputLayout.error = null - try { - vm.updateIdentityKey(keyText.fromHex()) - } catch (_: Exception) { - // Fallback - should not happen with regex validation - identityKeyInputLayout.error = getString(R.string.profiles_key_invalid_format) - } - } - else -> { - identityKeyInputLayout.error = getString(R.string.profiles_key_expected_format, exampleKey) - // Don't update ViewModel with invalid input - } - } - } - - encryptionKeyInput.doOnTextChanged { text, _, _, _ -> - val keyText = text?.toString() ?: "" - when { - keyText.isEmpty() -> { - encryptionKeyInputLayout.error = null - vm.updateEncryptionKey(null) - } - keyText.matches(keyRegex) -> { - encryptionKeyInputLayout.error = null - try { - vm.updateEncryptionKey(keyText.fromHex()) - } catch (_: Exception) { - // Fallback - should not happen with regex validation - encryptionKeyInputLayout.error = getString(R.string.profiles_key_invalid_format) - } - } - else -> { - encryptionKeyInputLayout.error = getString(R.string.profiles_key_expected_format, exampleKey) - // Don't update ViewModel with invalid input - } - } - } - - identityKeyGuideButton.setOnClickListener { - webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") - } - - encryptionKeyGuideButton.setOnClickListener { - webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") - } - - // Set hints using same format as AirPodKeyInputDialog - identityKeyInputLayout.hint = getString(R.string.general_example_label, exampleKey) - encryptionKeyInputLayout.hint = getString(R.string.general_example_label, exampleKey) - - - // Setup model dropdown - val modelAdapter = ModelArrayAdapter(requireContext(), vm.availableModels) - modelInput.setAdapter(modelAdapter) - modelInput.setOnItemClickListener { _, _, position, _ -> - val selectedModel = vm.availableModels[position] - vm.updateModel(selectedModel) - } - - } - - vm.bondedDevices.observe2(ui) { devices -> - val deviceAdapter = DeviceArrayAdapter(requireContext(), devices) - deviceInput.setAdapter(deviceAdapter) - deviceInput.setOnItemClickListener { _, _, position, _ -> - if (position == 0) { - // "None" option selected - vm.updateSelectedDevice(null) - } else { - // Regular device selected (position - 1 to account for "None" option) - val selectedDevice = devices[position - 1] - vm.updateSelectedDevice(selectedDevice) - } - } - } - - - vm.name.observe2(ui) { name -> - if (nameInput.text?.toString() != name) { - nameInput.setText(name) - } - } - - vm.selectedModel.observe2(ui) { model -> - model?.let { - if (modelInput.text?.toString() != it.label) { - modelInput.setText(it.label, false) - } - } - } - - - vm.nameError.observe2(ui) { error -> - nameInputLayout.error = error - } - - - vm.canSave.observe2(ui) { canSave -> - toolbar.menu.findItem(R.id.action_save)?.let { saveItem -> - saveItem.isEnabled = canSave - // Visual feedback for disabled state - val alpha = if (canSave) 255 else 128 - saveItem.icon?.alpha = alpha - } - } - - vm.identityKey.observe2(ui) { key -> - val keyText = key?.toHex() ?: "" - if (identityKeyInput.text?.toString() != keyText) { - identityKeyInput.setText(keyText) - } - } - - vm.encryptionKey.observe2(ui) { key -> - val keyText = key?.toHex() ?: "" - if (encryptionKeyInput.text?.toString() != keyText) { - encryptionKeyInput.setText(keyText) - } - } - - vm.selectedDevice.observe2(ui) { device -> - val deviceText = if (device != null) { - "${device.name ?: getString(R.string.pods_unknown_label)} (${device.address})" - } else { - getString(R.string.profiles_paired_device_none) - } - if (deviceInput.text?.toString() != deviceText) { - deviceInput.setText(deviceText, false) - } - } - - vm.minimumSignalQuality.observe2(ui) { quality -> - val percentage = (quality * 100).toInt() - signalQualityStatus.text = "$percentage%" - if (signalQualitySlider.value != quality * 100f) { - signalQualitySlider.value = quality * 100f - } - } - - super.onViewCreated(view, savedInstanceState) - } - - private fun showDeleteConfirmation() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.profiles_delete_title) - .setMessage(R.string.profiles_delete_message) - .setPositiveButton(R.string.profiles_delete_action) { _, _ -> - vm.deleteProfile() - } - .setNegativeButton(R.string.general_cancel_action, null) - .show() - } - - private fun showUnsavedChangesConfirmation() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.general_unsaved_changes_title) - .setMessage(R.string.general_unsaved_changes_message) - .setPositiveButton(R.string.general_save_and_exit_action) { _, _ -> - vm.saveProfile() - } - .setNegativeButton(R.string.general_discard_action) { _, _ -> - vm.discardChanges() - } - .setNeutralButton(R.string.general_keep_editing_action, null) - .show() - } - - private class ModelArrayAdapter( - context: Context, - models: List - ) : ArrayAdapter(context, R.layout.model_dropdown_item, models) { - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - return createView(position, convertView, parent) - } - - override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { - return createView(position, convertView, parent) - } - - private fun createView(position: Int, convertView: View?, parent: ViewGroup): View { - val model = getItem(position)!! - val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.model_dropdown_item, parent, false) - - val iconView = view.findViewById(R.id.model_icon) - val nameView = view.findViewById(R.id.model_name) - - iconView.setImageResource(model.iconRes) - nameView.text = model.label - - return view - } - } - - private class DeviceArrayAdapter( - context: Context, - private val devices: List - ) : ArrayAdapter(context, R.layout.paired_device_dropdown_item) { - - init { - // Add "None" as first item, then all device names - add(context.getString(R.string.profiles_paired_device_none)) - devices.forEach { device -> - add("${device.name ?: context.getString(R.string.pods_unknown_label)} (${device.address})") - } - } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - return createView(position, convertView, parent) - } - - override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { - return createView(position, convertView, parent) - } - - private fun createView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.paired_device_dropdown_item, parent, false) - - val nameView = view.findViewById(R.id.device_name) - val addressView = view.findViewById(R.id.device_address) - - if (position == 0) { - // "None" option - nameView.text = context.getString(R.string.profiles_paired_device_none) - addressView.text = context.getString(R.string.profiles_paired_device_none_description) - } else { - // Regular device (position - 1 to account for "None" option) - val device = devices[position - 1] - nameView.text = device.name ?: context.getString(R.string.pods_unknown_label) - addressView.text = device.address - } - - return view - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationNavigation.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationNavigation.kt new file mode 100644 index 00000000..fdac8ed2 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationNavigation.kt @@ -0,0 +1,26 @@ +package eu.darken.capod.profiles.ui.creation + +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 DeviceProfileCreationNavigation @Inject constructor() : NavigationEntry { + override fun EntryProviderScope.setup() { + entry { DeviceProfileCreationScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: DeviceProfileCreationNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt new file mode 100644 index 00000000..16428eb9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt @@ -0,0 +1,571 @@ +package eu.darken.capod.profiles.ui.creation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import eu.darken.capod.R +import eu.darken.capod.common.bluetooth.BluetoothDevice2 +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.toHex +import eu.darken.capod.pods.core.PodDevice + +@Composable +fun DeviceProfileCreationScreenHost(vm: DeviceProfileCreationViewModel = hiltViewModel()) { + ErrorEventHandler(vm) + NavigationEventHandler(vm) + + val state by waitForState(vm.state) + + var showUnsavedChangesDialog by rememberSaveable { mutableStateOf(false) } + var showDeleteDialog by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(vm.showUnsavedChangesEvent) { + vm.showUnsavedChangesEvent.collect { showUnsavedChangesDialog = true } + } + LaunchedEffect(vm.showDeleteConfirmationEvent) { + vm.showDeleteConfirmationEvent.collect { showDeleteDialog = true } + } + + BackHandler { + vm.onBackPressed() + } + + state?.let { + DeviceProfileCreationScreen( + state = it, + onBack = { vm.onBackPressed() }, + onSave = { vm.saveProfile() }, + onDelete = { vm.requestDeleteProfile() }, + onNameChange = { name -> vm.updateName(name) }, + onModelChange = { model -> vm.updateModel(model) }, + onDeviceChange = { device -> vm.updateSelectedDevice(device) }, + onIdentityKeyChange = { key -> vm.updateIdentityKey(key) }, + onEncryptionKeyChange = { key -> vm.updateEncryptionKey(key) }, + onSignalQualityChange = { quality -> vm.updateMinimumSignalQuality(quality) }, + onKeyGuide = { vm.openKeyGuide() }, + ) + } + + if (showUnsavedChangesDialog) { + UnsavedChangesDialog( + onSave = { + showUnsavedChangesDialog = false + vm.saveProfile() + }, + onDiscard = { + showUnsavedChangesDialog = false + vm.discardChanges() + }, + onKeepEditing = { + showUnsavedChangesDialog = false + }, + ) + } + + if (showDeleteDialog) { + DeleteConfirmationDialog( + onDelete = { + showDeleteDialog = false + vm.deleteProfile() + }, + onCancel = { + showDeleteDialog = false + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceProfileCreationScreen( + state: DeviceProfileCreationViewModel.State, + onBack: () -> Unit, + onSave: () -> Unit, + onDelete: () -> Unit, + onNameChange: (String) -> Unit, + onModelChange: (PodDevice.Model) -> Unit, + onDeviceChange: (BluetoothDevice2?) -> Unit, + onIdentityKeyChange: (ByteArray?) -> Unit, + onEncryptionKeyChange: (ByteArray?) -> Unit, + onSignalQualityChange: (Float) -> Unit, + onKeyGuide: () -> Unit, +) { + val title = if (state.isEditMode) { + stringResource(R.string.settings_devices_label) + } else { + stringResource(R.string.profiles_create_title) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + actions = { + IconButton( + onClick = onSave, + enabled = state.canSave, + ) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(R.string.profiles_save_action), + tint = if (state.canSave) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + if (state.isEditMode) { + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.profiles_delete_action), + ) + } + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // Device Information Card + DeviceInfoCard( + name = state.name, + nameError = state.nameError, + selectedModel = state.selectedModel, + availableModels = state.availableModels, + selectedDevice = state.selectedDevice, + bondedDevices = state.bondedDevices, + onNameChange = onNameChange, + onModelChange = onModelChange, + onDeviceChange = onDeviceChange, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Identity Key Card + KeyCard( + title = stringResource(R.string.profiles_identitykey_label), + description = stringResource(R.string.profiles_maindevice_identitykey_explanation), + keyValue = state.identityKey, + onKeyChange = onIdentityKeyChange, + onKeyGuide = onKeyGuide, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Encryption Key Card + KeyCard( + title = stringResource(R.string.profiles_maindevice_encryptionkey_label), + description = stringResource(R.string.profiles_maindevice_encryptionkey_explanation), + keyValue = state.encryptionKey, + onKeyChange = onEncryptionKeyChange, + onKeyGuide = onKeyGuide, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Signal Quality Card + SignalQualityCard( + minimumSignalQuality = state.minimumSignalQuality, + onSignalQualityChange = onSignalQualityChange, + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeviceInfoCard( + name: String, + nameError: String?, + selectedModel: PodDevice.Model?, + availableModels: List, + selectedDevice: BluetoothDevice2?, + bondedDevices: List, + onNameChange: (String) -> Unit, + onModelChange: (PodDevice.Model) -> Unit, + onDeviceChange: (BluetoothDevice2?) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_basic_info_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.profiles_basic_info_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + + // Name input + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(text = stringResource(R.string.profiles_name_label)) }, + isError = nameError != null, + supportingText = nameError?.let { { Text(text = it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Model dropdown + var modelExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = modelExpanded, + onExpandedChange = { modelExpanded = it }, + ) { + OutlinedTextField( + value = selectedModel?.label ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(text = stringResource(R.string.profiles_model_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = modelExpanded, + onDismissRequest = { modelExpanded = false }, + ) { + availableModels.forEach { model -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(model.iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = model.label) + } + }, + onClick = { + onModelChange(model) + modelExpanded = false + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Paired device dropdown + var deviceExpanded by remember { mutableStateOf(false) } + val noneLabel = stringResource(R.string.profiles_paired_device_none) + val unknownLabel = stringResource(R.string.pods_unknown_label) + val deviceDisplayText = if (selectedDevice != null) { + "${selectedDevice.name ?: unknownLabel} (${selectedDevice.address})" + } else { + noneLabel + } + + ExposedDropdownMenuBox( + expanded = deviceExpanded, + onExpandedChange = { deviceExpanded = it }, + ) { + OutlinedTextField( + value = deviceDisplayText, + onValueChange = {}, + readOnly = true, + label = { Text(text = stringResource(R.string.profiles_paired_device_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = deviceExpanded, + onDismissRequest = { deviceExpanded = false }, + ) { + // "None" option + DropdownMenuItem( + text = { + Column { + Text(text = noneLabel) + Text( + text = stringResource(R.string.profiles_paired_device_none_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onDeviceChange(null) + deviceExpanded = false + }, + ) + bondedDevices.forEach { device -> + DropdownMenuItem( + text = { + Column { + Text(text = device.name ?: unknownLabel) + Text( + text = device.address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onDeviceChange(device) + deviceExpanded = false + }, + ) + } + } + } + } + } +} + +@Composable +private fun KeyCard( + title: String, + description: String, + keyValue: ByteArray?, + onKeyChange: (ByteArray?) -> Unit, + onKeyGuide: () -> Unit, +) { + val keyRegex = remember { Regex("^([0-9A-Fa-f]{2}[- ]){15}[0-9A-Fa-f]{2}$") } + val exampleKey = "FE-D0-1C-54-11-81-BC-BC-87-D2-C4-3F-31-64-5F-EE" + + var textFieldValue by rememberSaveable { mutableStateOf(keyValue?.toHex() ?: "") } + var keyError by rememberSaveable { mutableStateOf(null) } + + // Sync from external state changes (e.g. profile load) + LaunchedEffect(keyValue) { + val externalHex = keyValue?.toHex() ?: "" + if (textFieldValue.replace(" ", "").replace("-", "") != + externalHex.replace(" ", "").replace("-", "") + ) { + textFieldValue = externalHex + keyError = null + } + } + + val invalidFormatLabel = stringResource(R.string.profiles_key_invalid_format) + val expectedFormatLabel = stringResource(R.string.profiles_key_expected_format, exampleKey) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = textFieldValue, + onValueChange = { text -> + textFieldValue = text + when { + text.isEmpty() -> { + keyError = null + onKeyChange(null) + } + + text.matches(keyRegex) -> { + keyError = null + try { + val normalized = text + .replace(" ", "") + .replace("-", "") + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + onKeyChange(normalized) + } catch (_: Exception) { + keyError = invalidFormatLabel + } + } + + else -> { + keyError = expectedFormatLabel + } + } + }, + label = { Text(text = stringResource(R.string.general_example_label, exampleKey)) }, + isError = keyError != null, + supportingText = keyError?.let { { Text(text = it) } }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onKeyGuide, + modifier = Modifier.align(Alignment.End), + ) { + Text( + text = stringResource(R.string.general_guide_action), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +@Composable +private fun SignalQualityCard( + minimumSignalQuality: Float, + onSignalQualityChange: (Float) -> Unit, +) { + val percentage = (minimumSignalQuality * 100).toInt() + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_signal_quality_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.profiles_signal_quality_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Slider( + value = minimumSignalQuality * 100f, + onValueChange = { onSignalQualityChange(it / 100f) }, + valueRange = 0f..100f, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "$percentage%", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun UnsavedChangesDialog( + onSave: () -> Unit, + onDiscard: () -> Unit, + onKeepEditing: () -> Unit, +) { + AlertDialog( + onDismissRequest = onKeepEditing, + title = { Text(text = stringResource(R.string.general_unsaved_changes_title)) }, + text = { Text(text = stringResource(R.string.general_unsaved_changes_message)) }, + confirmButton = { + TextButton(onClick = onSave) { + Text(text = stringResource(R.string.general_save_and_exit_action)) + } + }, + dismissButton = { + TextButton(onClick = onDiscard) { + Text(text = stringResource(R.string.general_discard_action)) + } + }, + ) +} + +@Composable +private fun DeleteConfirmationDialog( + onDelete: () -> Unit, + onCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(text = stringResource(R.string.profiles_delete_title)) }, + text = { Text(text = stringResource(R.string.profiles_delete_message)) }, + confirmButton = { + TextButton(onClick = onDelete) { + Text(text = stringResource(R.string.profiles_delete_action)) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(text = stringResource(R.string.general_cancel_action)) + } + }, + ) +} diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationFragmentVM.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt similarity index 61% rename from app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationFragmentVM.kt rename to app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt index 937a75be..60308ae0 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt @@ -1,20 +1,21 @@ package eu.darken.capod.profiles.ui.creation import android.content.Context -import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.asLiveData import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.R +import eu.darken.capod.common.WebpageTool import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothManager2 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.SingleEventFlow +import eu.darken.capod.common.flow.shareLatest import eu.darken.capod.common.fromHex import eu.darken.capod.common.toHex -import eu.darken.capod.common.uix.ViewModel3 +import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey @@ -26,128 +27,118 @@ import eu.darken.capod.profiles.core.currentProfiles import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import java.util.UUID import javax.inject.Inject -data class ProfileEditorState( +private data class ProfileEditorState( val name: String = "", val selectedModel: PodDevice.Model? = null, val identityKeyHex: String? = null, val encryptionKeyHex: String? = null, val selectedDeviceAddress: String? = null, - val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY + val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY, ) @HiltViewModel -class DeviceProfileCreationFragmentVM @Inject constructor( +class DeviceProfileCreationViewModel @Inject constructor( @ApplicationContext private val context: Context, handle: SavedStateHandle, dispatcherProvider: DispatcherProvider, private val deviceProfilesRepo: DeviceProfilesRepo, private val bluetoothManager: BluetoothManager2, -) : ViewModel3(dispatcherProvider = dispatcherProvider) { + private val webpageTool: WebpageTool, +) : ViewModel4(dispatcherProvider) { - private val profileId: String? = handle.get("profileId") - val isEditMode: Boolean = profileId != null + private val profileId: ProfileId? = handle.get("profileId") + private val isEditMode: Boolean = profileId != null private val _currentState = MutableStateFlow(ProfileEditorState()) private val _initialState = MutableStateFlow(ProfileEditorState()) - private val _nameError = MutableStateFlow(null) - + + val showUnsavedChangesEvent = SingleEventFlow() + val showDeleteConfirmationEvent = SingleEventFlow() + init { if (isEditMode && profileId != null) { loadProfile(profileId) } else { - // Prefill with localizable default for new profiles val defaultName = context.getString(R.string.profiles_name_default) _currentState.value = _currentState.value.copy(name = defaultName) - // Initial state remains empty for create mode } } - val name: LiveData = _currentState.map { it.name }.asLiveData() - val selectedModel: LiveData = _currentState.map { it.selectedModel }.asLiveData() - val nameError: LiveData = _nameError.asLiveData() - val identityKey: LiveData = _currentState.map { - it.identityKeyHex?.fromHex() - }.asLiveData() - val encryptionKey: LiveData = _currentState.map { - it.encryptionKeyHex?.fromHex() - }.asLiveData() - val selectedDevice: LiveData = combine( - _currentState, - bluetoothManager.bondedDevices().catch { emit(emptySet()) } - ) { state: ProfileEditorState, devices: Set -> - state.selectedDeviceAddress?.let { address -> - devices.find { it.address == address } - } - }.asLiveData() - val minimumSignalQuality: LiveData = _currentState.map { it.minimumSignalQuality }.asLiveData() - - val availableModels: List = PodDevice.Model.entries - - val bondedDevices: LiveData> = bluetoothManager.bondedDevices() + private val bondedDevicesFlow = bluetoothManager.bondedDevices() .catch { emit(emptySet()) } .map { it.toList() } - .asLiveData() private val hasUnsavedChangesFlow = combine( _currentState, _initialState ) { current, initial -> if (isEditMode) { - // For edit mode, check if current state differs from initial state current != initial } else { - // For create mode, check if any meaningful values have been entered current.name.isNotBlank() || - current.selectedModel != null || - current.identityKeyHex != null || - current.encryptionKeyHex != null || - current.selectedDeviceAddress != null || - current.minimumSignalQuality != DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY + current.selectedModel != null || + current.identityKeyHex != null || + current.encryptionKeyHex != null || + current.selectedDeviceAddress != null || + current.minimumSignalQuality != DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY } } private val isFormValid = combine( _currentState, - _nameError - ) { state, nameError -> - state.name.isNotBlank() && - state.selectedModel != null && - nameError == null + _nameError, + ) { editorState, nameErr -> + editorState.name.isNotBlank() && + editorState.selectedModel != null && + nameErr == null } - val canSave: LiveData = combine( + val state = combine( + _currentState, + _nameError, + bondedDevicesFlow, + hasUnsavedChangesFlow, isFormValid, - hasUnsavedChangesFlow - ) { valid, hasChanges -> - valid && hasChanges - }.asLiveData() - - fun hasUnsavedChanges(): Boolean { - val current = _currentState.value - val initial = _initialState.value - - return if (isEditMode) { - // For edit mode, check if current state differs from initial state - current != initial - } else { - // For create mode, check if any meaningful values have been entered - current.name.isNotBlank() || - current.selectedModel != null || - current.identityKeyHex != null || - current.encryptionKeyHex != null || - current.selectedDeviceAddress != null || - current.minimumSignalQuality != DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY + ) { editorState, nameErr, bonded, hasChanges, formValid -> + val selectedDevice = editorState.selectedDeviceAddress?.let { address -> + bonded.find { it.address == address } } - } + State( + isEditMode = isEditMode, + name = editorState.name, + nameError = nameErr, + selectedModel = editorState.selectedModel, + availableModels = PodDevice.Model.entries, + identityKey = editorState.identityKeyHex?.fromHex(), + encryptionKey = editorState.encryptionKeyHex?.fromHex(), + selectedDevice = selectedDevice, + bondedDevices = bonded, + minimumSignalQuality = editorState.minimumSignalQuality, + canSave = formValid && hasChanges, + ) + }.shareLatest(scope = vmScope) + + data class State( + val isEditMode: Boolean, + val name: String, + val nameError: String?, + val selectedModel: PodDevice.Model?, + val availableModels: List, + val identityKey: IdentityResolvingKey?, + val encryptionKey: ProximityEncryptionKey?, + val selectedDevice: BluetoothDevice2?, + val bondedDevices: List, + val minimumSignalQuality: Float, + val canSave: Boolean, + ) fun updateName(name: String) { _currentState.value = _currentState.value.copy(name = name) - _nameError.value = if (name.isBlank()) "Profile name is required" else null + _nameError.value = if (name.isBlank()) context.getString(R.string.profiles_name_empty_error) else null } fun updateModel(model: PodDevice.Model) { @@ -164,79 +155,68 @@ class DeviceProfileCreationFragmentVM @Inject constructor( _currentState.value = _currentState.value.copy(encryptionKeyHex = key?.toHex()) log(TAG) { "Encryption key updated: ${key != null}" } } - + fun updateSelectedDevice(device: BluetoothDevice2?) { _currentState.value = _currentState.value.copy(selectedDeviceAddress = device?.address) log(TAG) { "Selected device updated: ${device?.name} (${device?.address})" } } - + fun updateMinimumSignalQuality(quality: Float) { _currentState.value = _currentState.value.copy(minimumSignalQuality = quality) log(TAG) { "Minimum signal quality updated: $quality" } } - - private fun loadProfile(profileId: String) { - launch { - try { - val profiles = deviceProfilesRepo.currentProfiles() - val profile = profiles.find { it.id == profileId } - if (profile != null) { - // Create the loaded state - val loadedState = ProfileEditorState( - name = profile.label, - selectedModel = profile.model, - identityKeyHex = (profile as? AppleDeviceProfile)?.identityKey?.toHex(), - encryptionKeyHex = (profile as? AppleDeviceProfile)?.encryptionKey?.toHex(), - selectedDeviceAddress = (profile as? AppleDeviceProfile)?.address, - minimumSignalQuality = profile.minimumSignalQuality ?: DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY - ) - - // Set both initial and current state atomically - _initialState.value = loadedState - _currentState.value = loadedState - - log(TAG) { "Profile loaded: ${profile.label}" } - } else { - log(TAG) { "Profile not found: $profileId" } - errorEvents.postValue(IllegalArgumentException("Profile not found")) - } - } catch (e: Exception) { - log(TAG) { "Failed to load profile: $e" } - errorEvents.postValue(e) - } + + fun hasUnsavedChanges(): Boolean { + val current = _currentState.value + val initial = _initialState.value + return if (isEditMode) { + current != initial + } else { + current.name.isNotBlank() || + current.selectedModel != null || + current.identityKeyHex != null || + current.encryptionKeyHex != null || + current.selectedDeviceAddress != null || + current.minimumSignalQuality != DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY } } + fun onBackPressed() { + log(TAG) { "onBackPressed()" } + if (hasUnsavedChanges()) { + showUnsavedChangesEvent.tryEmit(Unit) + } else { + navUp() + } + } fun saveProfile() { log(TAG) { "saveProfile()" } - - val state = _currentState.value - val name = state.name.trim() - + val editorState = _currentState.value + val name = editorState.name.trim() + if (name.isBlank()) { - _nameError.value = "Profile name is required" + _nameError.value = context.getString(R.string.profiles_name_empty_error) return } - - if (state.selectedModel == null) { + + if (editorState.selectedModel == null) { log(TAG) { "No model selected" } return } - - + launch { try { val profile = AppleDeviceProfile( id = if (isEditMode) profileId!! else UUID.randomUUID().toString(), label = name, - model = state.selectedModel, - minimumSignalQuality = state.minimumSignalQuality, - identityKey = state.identityKeyHex?.fromHex(), - encryptionKey = state.encryptionKeyHex?.fromHex(), - address = state.selectedDeviceAddress + model = editorState.selectedModel, + minimumSignalQuality = editorState.minimumSignalQuality, + identityKey = editorState.identityKeyHex?.fromHex(), + encryptionKey = editorState.encryptionKeyHex?.fromHex(), + address = editorState.selectedDeviceAddress, ) - + if (isEditMode) { deviceProfilesRepo.updateProfile(profile) log(TAG) { "Profile updated: $profile" } @@ -244,49 +224,72 @@ class DeviceProfileCreationFragmentVM @Inject constructor( deviceProfilesRepo.addProfile(profile) log(TAG) { "Profile created: $profile" } } - popBackStack() + navUp() } catch (e: Exception) { log(TAG) { "Failed to save profile: $e" } - errorEvents.postValue(e) + errorEvents.emitBlocking(e) } } } + fun requestDeleteProfile() { + log(TAG) { "requestDeleteProfile()" } + showDeleteConfirmationEvent.tryEmit(Unit) + } + fun deleteProfile() { if (isEditMode && profileId != null) { launch { try { deviceProfilesRepo.removeProfile(profileId) log(TAG) { "Profile deleted: $profileId" } - popBackStack() + navUp() } catch (e: Exception) { log(TAG) { "Failed to delete profile: $e" } - errorEvents.postValue(e) + errorEvents.emitBlocking(e) } } } } - fun onBackPressed() { - log(TAG) { "onBackPressed()" } - if (hasUnsavedChanges()) { - // Let the fragment handle the confirmation dialog - // The fragment will call either discardChanges() or saveProfile() - } else { - popBackStack() - } - } - fun discardChanges() { log(TAG) { "discardChanges()" } - popBackStack() + navUp() } - private fun popBackStack() { - navEvents.postValue(null) + fun openKeyGuide() { + webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") + } + + private fun loadProfile(profileId: String) { + launch { + try { + val profiles = deviceProfilesRepo.currentProfiles() + val profile = profiles.find { it.id == profileId } + if (profile != null) { + val loadedState = ProfileEditorState( + name = profile.label, + selectedModel = profile.model, + identityKeyHex = (profile as? AppleDeviceProfile)?.identityKey?.toHex(), + encryptionKeyHex = (profile as? AppleDeviceProfile)?.encryptionKey?.toHex(), + selectedDeviceAddress = (profile as? AppleDeviceProfile)?.address, + minimumSignalQuality = profile.minimumSignalQuality ?: DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY, + ) + _initialState.value = loadedState + _currentState.value = loadedState + log(TAG) { "Profile loaded: ${profile.label}" } + } else { + log(TAG) { "Profile not found: $profileId" } + errorEvents.emitBlocking(IllegalArgumentException("Profile not found")) + } + } catch (e: Exception) { + log(TAG) { "Failed to load profile: $e" } + errorEvents.emitBlocking(e) + } + } } companion object { private val TAG = logTag("DeviceProfileCreation", "ViewModel") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragment.kt b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragment.kt deleted file mode 100644 index 530bdb59..00000000 --- a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragment.kt +++ /dev/null @@ -1,102 +0,0 @@ -package eu.darken.capod.reaction.ui - -import android.os.Bundle -import android.view.View -import androidx.annotation.Keep -import androidx.fragment.app.viewModels -import androidx.lifecycle.asLiveData -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.TwoStatePreference -import dagger.hilt.android.AndroidEntryPoint -import eu.darken.capod.R -import eu.darken.capod.common.bluetooth.BluetoothDevice2 -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.reaction.core.ReactionSettings -import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition -import javax.inject.Inject - -@Keep -@AndroidEntryPoint -class ReactionSettingsFragment : PreferenceFragment3() { - - override val vm: ReactionSettingsFragmentVM by viewModels() - - @Inject lateinit var generalSettings: GeneralSettings - @Inject lateinit var reactionSettings: ReactionSettings - @Inject lateinit var upgradeRepo: UpgradeRepo - - override val settings: ReactionSettings - get() = reactionSettings - - override val preferenceFile: Int = R.xml.preferences_reactions - - private var isPro: Boolean = false - private var bondedDevices: List = emptyList() - private val autoConnectConditionPref by lazy { findPreference(settings.autoConnectCondition.key)!! } - - override fun onPreferencesCreated() { - autoConnectConditionPref.apply { - entries = AutoConnectCondition.values().map { getString(it.labelRes) }.toTypedArray() - entryValues = AutoConnectCondition.values().map { settings.autoConnectCondition.rawWriter(it) as String } - .toTypedArray() - } - - super.onPreferencesCreated() - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - if (preference.key == reactionSettings.autoPlay.key && !isPro) { - preference as TwoStatePreference - upgradeRepo.launchBillingFlow(requireActivity()) - preference.isChecked = false - return true - } else if (preference.key == reactionSettings.autoPause.key && !isPro) { - preference as TwoStatePreference - upgradeRepo.launchBillingFlow(requireActivity()) - preference.isChecked = false - return true - } else if (preference.key == reactionSettings.autoConnect.key) { - preference as TwoStatePreference - if (!isPro) { - upgradeRepo.launchBillingFlow(requireActivity()) - preference.isChecked = false - return true - } - } else if (preference.key == reactionSettings.showPopUpOnCaseOpen.key && !isPro) { - preference as TwoStatePreference - upgradeRepo.launchBillingFlow(requireActivity()) - preference.isChecked = false - return true - } else if (preference.key == reactionSettings.showPopUpOnConnection.key && !isPro) { - preference as TwoStatePreference - upgradeRepo.launchBillingFlow(requireActivity()) - preference.isChecked = false - return true - } - - return super.onPreferenceTreeClick(preference) - } - - // Some UI interactions shortly turn autoConnect on, then off again - private val previousMonitorMode by lazy { generalSettings.monitorMode.value } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - settings.autoConnect.flow.asLiveData().observe2 { isEnabled -> - if (isEnabled) generalSettings.monitorMode.value = MonitorMode.ALWAYS - else generalSettings.monitorMode.value = previousMonitorMode - - autoConnectConditionPref.isEnabled = isEnabled - } - - vm.isPro.observe2 { isPro = it } - - vm.bondedDevices.observe2 { bondedDevices = it } - - super.onViewCreated(view, savedInstanceState) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragmentVM.kt b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragmentVM.kt deleted file mode 100644 index 13529c93..00000000 --- a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragmentVM.kt +++ /dev/null @@ -1,30 +0,0 @@ -package eu.darken.capod.reaction.ui - -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.logging.logTag -import eu.darken.capod.common.uix.ViewModel3 -import eu.darken.capod.common.upgrade.UpgradeRepo -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -@HiltViewModel -class ReactionSettingsFragmentVM @Inject constructor( - private val handle: SavedStateHandle, - private val dispatcherProvider: DispatcherProvider, - private val bluetoothManager: BluetoothManager2, - private val upgradeRepo: UpgradeRepo, -) : ViewModel3(dispatcherProvider) { - - val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveData2() - - val bondedDevices = bluetoothManager.bondedDevices() - .map { it.toList() } - .asLiveData2() - - companion object { - private val TAG = logTag("Settings", "Reaction", "VM") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsNavigation.kt b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsNavigation.kt new file mode 100644 index 00000000..e250f4ee --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsNavigation.kt @@ -0,0 +1,26 @@ +package eu.darken.capod.reaction.ui + +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 ReactionSettingsNavigation @Inject constructor() : NavigationEntry { + override fun EntryProviderScope.setup() { + entry { ReactionSettingsScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: ReactionSettingsNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsScreen.kt b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsScreen.kt new file mode 100644 index 00000000..821341bf --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsScreen.kt @@ -0,0 +1,263 @@ +package eu.darken.capod.reaction.ui + +import android.app.Activity +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.platform.LocalContext +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.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.reaction.core.autoconnect.AutoConnectCondition + +@Composable +fun ReactionSettingsScreenHost(vm: ReactionSettingsViewModel = hiltViewModel()) { + ErrorEventHandler(vm) + NavigationEventHandler(vm) + + val state by waitForState(vm.state) + val activity = LocalContext.current as Activity + + state?.let { + ReactionSettingsScreen( + state = it, + onNavigateUp = { vm.navUp() }, + onOnePodModeChanged = { enabled -> vm.setOnePodMode(enabled) }, + onAutoPlayChanged = { enabled -> vm.setAutoPlay(enabled, activity) }, + onAutoPauseChanged = { enabled -> vm.setAutoPause(enabled, activity) }, + onAutoConnectChanged = { enabled -> vm.setAutoConnect(enabled, activity) }, + onAutoConnectConditionSelected = { condition -> vm.setAutoConnectCondition(condition) }, + onShowPopUpOnCaseOpenChanged = { enabled -> vm.setShowPopUpOnCaseOpen(enabled, activity) }, + onShowPopUpOnConnectionChanged = { enabled -> vm.setShowPopUpOnConnection(enabled, activity) }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReactionSettingsScreen( + state: ReactionSettingsViewModel.State, + onNavigateUp: () -> Unit, + onOnePodModeChanged: (Boolean) -> Unit, + onAutoPlayChanged: (Boolean) -> Unit, + onAutoPauseChanged: (Boolean) -> Unit, + onAutoConnectChanged: (Boolean) -> Unit, + onAutoConnectConditionSelected: (AutoConnectCondition) -> Unit, + onShowPopUpOnCaseOpenChanged: (Boolean) -> Unit, + onShowPopUpOnConnectionChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var showAutoConnectConditionDialog by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.settings_reaction_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.settings_autopplay_label)) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_onepod_mode_label), + subtitle = stringResource(R.string.settings_onepod_mode_description), + iconPainter = painterResource(R.drawable.ic_baseline_looks_one_24), + onClick = { onOnePodModeChanged(!state.onePodMode) }, + trailingContent = { + Switch( + checked = state.onePodMode, + onCheckedChange = onOnePodModeChanged, + modifier = Modifier.padding(start = 16.dp), + ) + }, + ) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_autopplay_label), + subtitle = stringResource(R.string.settings_autoplay_description), + iconPainter = painterResource(R.drawable.ic_baseline_play_circle_24), + onClick = { onAutoPlayChanged(!state.autoPlay) }, + trailingContent = { + Switch( + checked = state.autoPlay, + onCheckedChange = onAutoPlayChanged, + modifier = Modifier.padding(start = 16.dp), + ) + }, + ) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_autopause_label), + subtitle = stringResource(R.string.settings_autopause_description), + iconPainter = painterResource(R.drawable.ic_baseline_pause_circle_24), + onClick = { onAutoPauseChanged(!state.autoPause) }, + trailingContent = { + Switch( + checked = state.autoPause, + onCheckedChange = onAutoPauseChanged, + modifier = Modifier.padding(start = 16.dp), + ) + }, + ) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_eardetection_info_label), + subtitle = stringResource(R.string.settings_eardetection_info_description), + iconPainter = painterResource(R.drawable.ic_baseline_question_mark_24), + onClick = {}, + enabled = false, + ) + } + item { + SettingsCategoryHeader(text = stringResource(R.string.settings_autoconnect_label)) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_autoconnect_label), + subtitle = stringResource(R.string.settings_autoconnect_description), + iconPainter = painterResource(R.drawable.ic_baseline_bluetooth_connected_24), + onClick = { onAutoConnectChanged(!state.autoConnect) }, + trailingContent = { + Switch( + checked = state.autoConnect, + onCheckedChange = onAutoConnectChanged, + modifier = Modifier.padding(start = 16.dp), + ) + }, + ) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_autoconnect_condition_label), + subtitle = stringResource(state.autoConnectCondition.labelRes), + iconPainter = painterResource(R.drawable.ic_baseline_workspaces_24), + onClick = { if (state.autoConnect) showAutoConnectConditionDialog = true }, + enabled = state.autoConnect, + ) + } + item { + SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label)) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_popup_caseopen_label), + subtitle = stringResource(R.string.settings_popup_caseopen_description), + iconPainter = painterResource(R.drawable.ic_message_outline_24), + onClick = { onShowPopUpOnCaseOpenChanged(!state.showPopUpOnCaseOpen) }, + trailingContent = { + Switch( + checked = state.showPopUpOnCaseOpen, + onCheckedChange = onShowPopUpOnCaseOpenChanged, + modifier = Modifier.padding(start = 16.dp), + ) + }, + ) + } + item { + SettingsBaseItem( + title = stringResource(R.string.settings_popup_connected_label), + subtitle = stringResource(R.string.settings_popup_connected_description), + iconPainter = painterResource(R.drawable.ic_message_24), + onClick = { onShowPopUpOnConnectionChanged(!state.showPopUpOnConnection) }, + trailingContent = { + Switch( + checked = state.showPopUpOnConnection, + onCheckedChange = onShowPopUpOnConnectionChanged, + modifier = Modifier.padding(start = 16.dp), + ) + }, + ) + } + } + } + + if (showAutoConnectConditionDialog) { + AlertDialog( + onDismissRequest = { showAutoConnectConditionDialog = false }, + title = { Text(text = stringResource(R.string.settings_autoconnect_condition_label)) }, + text = { + Column(Modifier.selectableGroup()) { + AutoConnectCondition.entries.forEach { condition -> + val isSelected = condition == state.autoConnectCondition + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = { + onAutoConnectConditionSelected(condition) + showAutoConnectConditionDialog = false + }, + role = Role.RadioButton, + ) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = isSelected, + onClick = null, + ) + Text( + text = stringResource(condition.labelRes), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showAutoConnectConditionDialog = false }) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModel.kt new file mode 100644 index 00000000..a305f158 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModel.kt @@ -0,0 +1,134 @@ +package eu.darken.capod.reaction.ui + +import android.app.Activity +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.flow.shareLatest +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.reaction.core.ReactionSettings +import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class ReactionSettingsViewModel @Inject constructor( + dispatcherProvider: DispatcherProvider, + private val reactionSettings: ReactionSettings, + private val generalSettings: GeneralSettings, + private val upgradeRepo: UpgradeRepo, +) : ViewModel4(dispatcherProvider) { + + data class State( + val isPro: Boolean, + val onePodMode: Boolean, + val autoPlay: Boolean, + val autoPause: Boolean, + val autoConnect: Boolean, + val autoConnectCondition: AutoConnectCondition, + val showPopUpOnCaseOpen: Boolean, + val showPopUpOnConnection: Boolean, + ) + + private val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.shareLatest(scope = vmScope) + + val state = combine( + isPro, + reactionSettings.onePodMode.flow, + reactionSettings.autoPlay.flow, + reactionSettings.autoPause.flow, + reactionSettings.autoConnect.flow, + reactionSettings.autoConnectCondition.flow, + reactionSettings.showPopUpOnCaseOpen.flow, + reactionSettings.showPopUpOnConnection.flow, + ) { values -> + State( + isPro = values[0] as Boolean, + onePodMode = values[1] as Boolean, + autoPlay = values[2] as Boolean, + autoPause = values[3] as Boolean, + autoConnect = values[4] as Boolean, + autoConnectCondition = values[5] as AutoConnectCondition, + showPopUpOnCaseOpen = values[6] as Boolean, + showPopUpOnConnection = values[7] as Boolean, + ) + }.shareLatest(scope = vmScope) + + fun setOnePodMode(enabled: Boolean) { + reactionSettings.onePodMode.value = enabled + } + + fun setAutoPlay(enabled: Boolean, activity: Activity) = launch { + if (!enabled) { + reactionSettings.autoPlay.value = false + return@launch + } + if (isPro.first()) { + reactionSettings.autoPlay.value = true + } else { + upgradeRepo.launchBillingFlow(activity) + } + } + + fun setAutoPause(enabled: Boolean, activity: Activity) = launch { + if (!enabled) { + reactionSettings.autoPause.value = false + return@launch + } + if (isPro.first()) { + reactionSettings.autoPause.value = true + } else { + upgradeRepo.launchBillingFlow(activity) + } + } + + fun setAutoConnect(enabled: Boolean, activity: Activity) = launch { + if (!enabled) { + reactionSettings.autoConnect.value = false + return@launch + } + if (isPro.first()) { + reactionSettings.autoConnect.value = true + generalSettings.monitorMode.value = MonitorMode.ALWAYS + } else { + upgradeRepo.launchBillingFlow(activity) + } + } + + fun setAutoConnectCondition(condition: AutoConnectCondition) { + reactionSettings.autoConnectCondition.value = condition + } + + fun setShowPopUpOnCaseOpen(enabled: Boolean, activity: Activity) = launch { + if (!enabled) { + reactionSettings.showPopUpOnCaseOpen.value = false + return@launch + } + if (isPro.first()) { + reactionSettings.showPopUpOnCaseOpen.value = true + } else { + upgradeRepo.launchBillingFlow(activity) + } + } + + fun setShowPopUpOnConnection(enabled: Boolean, activity: Activity) = launch { + if (!enabled) { + reactionSettings.showPopUpOnConnection.value = false + return@launch + } + if (isPro.first()) { + reactionSettings.showPopUpOnConnection.value = true + } else { + upgradeRepo.launchBillingFlow(activity) + } + } + + companion object { + private val TAG = logTag("Settings", "Reaction", "VM") + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt new file mode 100644 index 00000000..1f1cae4b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt @@ -0,0 +1,192 @@ +package eu.darken.capod.reaction.ui.popup + +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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.PodDevice +import eu.darken.capod.pods.core.SinglePodDevice +import eu.darken.capod.pods.core.formatBatteryPercent +import eu.darken.capod.pods.core.getBatteryDrawable +import eu.darken.capod.pods.core.getSignalQuality + +@Composable +fun PopUpContent( + device: PodDevice, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 20.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Header: label + signal quality + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = device.getLabel(context), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + + val signalText = device.getSignalQuality(context) + if (signalText.isNotBlank()) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_baseline_signal_cellular_alt_24), + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = signalText, + style = MaterialTheme.typography.labelSmall, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Device-specific content + when (device) { + is DualPodDevice -> DualPodContent(device) + is SinglePodDevice -> SinglePodContent(device) + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Close button + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + ) { + Text(text = stringResource(R.string.general_close_action)) + } + } + } +} + +@Composable +private fun DualPodContent(device: DualPodDevice) { + val hasCase = device as? HasCase + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + // Left pod + BatteryColumn( + iconRes = device.leftPodIcon, + batteryPercent = device.batteryLeftPodPercent, + modifier = Modifier.weight(1f), + ) + + // Case (only if device has one) + if (hasCase != null) { + BatteryColumn( + iconRes = hasCase.caseIcon, + batteryPercent = hasCase.batteryCasePercent, + modifier = Modifier.weight(1f), + ) + } + + // Right pod + BatteryColumn( + iconRes = device.rightPodIcon, + batteryPercent = device.batteryRightPodPercent, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun SinglePodContent(device: SinglePodDevice) { + BatteryColumn( + iconRes = device.iconRes, + batteryPercent = device.batteryHeadsetPercent, + ) +} + +@Composable +private fun BatteryColumn( + iconRes: Int, + batteryPercent: Float?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(48.dp), + contentScale = ContentScale.Fit, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(getBatteryDrawable(batteryPercent)), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = formatBatteryPercent(context, batteryPercent), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpPodViewFactory.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpPodViewFactory.kt deleted file mode 100644 index b1d8bd54..00000000 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpPodViewFactory.kt +++ /dev/null @@ -1,84 +0,0 @@ -package eu.darken.capod.reaction.ui.popup - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.view.ContextThemeWrapper -import androidx.core.view.isGone -import androidx.core.view.isVisible -import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.capod.R -import eu.darken.capod.databinding.PopupNotificationDualPodsBinding -import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding -import eu.darken.capod.pods.core.DualPodDevice -import eu.darken.capod.pods.core.HasCase -import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.SinglePodDevice -import eu.darken.capod.pods.core.formatBatteryPercent -import eu.darken.capod.pods.core.getBatteryDrawable -import eu.darken.capod.pods.core.getSignalQuality -import javax.inject.Inject - - -class PopUpPodViewFactory @Inject constructor( - @ApplicationContext private val appContext: Context, -) { - - private val context = ContextThemeWrapper(appContext, R.style.AppTheme) - private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - - fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) { - is DualPodDevice -> createDualPods(parent, device) - // Unused, has no case to trigger reaction? - is SinglePodDevice -> createSinglePod(parent, device) - else -> throw IllegalArgumentException("Unexpected device: $device") - } - - private fun createDualPods(parent: ViewGroup, device: DualPodDevice): View = - PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply { - podLabel.text = device.getLabel(context) - signal.text = device.getSignalQuality(context) - signalIcon.isGone = signal.text.isNullOrBlank() - signal.isGone = signal.text.isNullOrBlank() - - // Left - val leftPercent = device.batteryLeftPodPercent - podLeftIcon.setImageResource(device.leftPodIcon) - podLeftIcon.imageTintList = null - podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent)) - podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent) - - // Case - podCaseContainer.isVisible = device is HasCase - (device as? HasCase)?.let { case -> - val casePercent = case.batteryCasePercent - podCaseIcon.setImageResource(case.caseIcon) - podCaseIcon.imageTintList = null - podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent)) - podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent) - } - - // Right - val rightPercent = device.batteryRightPodPercent - podRightIcon.setImageResource(device.rightPodIcon) - podRightIcon.imageTintList = null - podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent)) - podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent) - }.root - - private fun createSinglePod(parent: ViewGroup, device: SinglePodDevice): View = - PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply { - headphonesIcon.setImageResource(device.iconRes) - headphonesIcon.imageTintList = null - headphonesLabel.text = device.getLabel(context) - signal.text = device.getSignalQuality(context) - signalIcon.isGone = signal.text.isNullOrBlank() - signal.isGone = signal.text.isNullOrBlank() - - val headsetPercent = device.batteryHeadsetPercent - headphonesBatteryIcon.setImageResource(getBatteryDrawable(headsetPercent)) - headphonesBatteryLabel.text = formatBatteryPercent(context, headsetPercent) - }.root - -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt index 4a99c56b..ec817cd2 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt @@ -4,18 +4,21 @@ import android.content.Context import android.content.Context.WINDOW_SERVICE import android.graphics.PixelFormat import android.view.Gravity -import android.view.LayoutInflater -import android.view.View import android.view.WindowManager -import android.widget.FrameLayout -import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.capod.R -import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR import eu.darken.capod.common.debug.logging.asLog 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.pods.core.PodDevice import javax.inject.Inject import javax.inject.Singleton @@ -23,13 +26,9 @@ import javax.inject.Singleton @Singleton class PopUpWindow @Inject constructor( @ApplicationContext private val appContext: Context, - private val podViewFactory: PopUpPodViewFactory, - private val dispatcherProvider: DispatcherProvider, ) { - private val context = ContextThemeWrapper(appContext, R.style.AppTheme) - private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - private val windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager + private val windowManager = appContext.getSystemService(WINDOW_SERVICE) as WindowManager private val layoutParams = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, @@ -42,40 +41,86 @@ class PopUpWindow @Inject constructor( val margin = (24 * dm.density).toInt() width = minOf(dm.widthPixels - margin * 2, (400 * dm.density).toInt()) } - private val popUpView: View = layoutInflater.inflate(R.layout.popup_window_container_layout, null).apply { - findViewById(R.id.close_action).setOnClickListener { close() } - } - private val deviceContainer = popUpView.findViewById(R.id.popup_content) + + private var composeView: ComposeView? = null + private var lifecycleOwner: OverlayLifecycleOwner? = null fun show(device: PodDevice) = try { log(TAG) { "open()" } - if (popUpView.windowToken != null || popUpView.parent != null) { - log(TAG) { "View already added." } - close() + // Unconditionally clean up any existing lifecycle owner before creating a new one + lifecycleOwner?.let { existing -> + existing.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + existing.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + existing.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } + if (composeView?.parent != null) { + log(TAG) { "View already added." } + windowManager.removeView(composeView) + } + composeView = null + lifecycleOwner = null - val podView = podViewFactory.createContentView(deviceContainer, device) - deviceContainer.removeAllViews() - deviceContainer.addView(podView) - windowManager.addView(popUpView, layoutParams) + val owner = OverlayLifecycleOwner() + lifecycleOwner = owner + + val view = ComposeView(appContext).apply { + setViewTreeLifecycleOwner(owner) + setViewTreeSavedStateRegistryOwner(owner) + setContent { + CapodTheme { + PopUpContent(device = device, onClose = { close() }) + } + } + } + composeView = view + + owner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + owner.handleLifecycleEvent(Lifecycle.Event.ON_START) + owner.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + + windowManager.addView(view, layoutParams) } catch (e: Exception) { log(TAG, ERROR) { "open() failed: ${e.asLog()}" } } - fun close() = try { log(TAG) { "close()" } - if (popUpView.parent != null) { - windowManager.removeView(popUpView) - deviceContainer.removeAllViews() + val view = composeView + if (view?.parent != null) { + lifecycleOwner?.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + lifecycleOwner?.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + lifecycleOwner?.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + windowManager.removeView(view) } else { log(TAG) { "View was not added" } } + composeView = null + lifecycleOwner = null } catch (e: Exception) { log(TAG, ERROR) { "close() failed: ${e.asLog()}" } } + /** + * Minimal [SavedStateRegistryOwner] for hosting a [ComposeView] inside a WindowManager overlay, + * where no Activity or Fragment lifecycle is available. + */ + private class OverlayLifecycleOwner : SavedStateRegistryOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + private val savedStateRegistryController = SavedStateRegistryController.create(this) + + init { + savedStateRegistryController.performRestore(null) + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + fun handleLifecycleEvent(event: Lifecycle.Event) { + lifecycleRegistry.handleLifecycleEvent(event) + } + } + companion object { private val TAG = logTag("Reaction", "PopUp", "Window") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragment.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragment.kt deleted file mode 100644 index c354850f..00000000 --- a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -package eu.darken.capod.troubleshooter.ui - -import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import androidx.navigation.ui.setupWithNavController -import dagger.hilt.android.AndroidEntryPoint -import eu.darken.capod.R -import eu.darken.capod.common.EdgeToEdgeHelper -import eu.darken.capod.common.WebpageTool -import eu.darken.capod.common.navigation.popBackStack -import eu.darken.capod.common.uix.Fragment3 -import eu.darken.capod.common.viewbinding.viewBinding -import eu.darken.capod.databinding.TroubleshooterFragmentBinding -import eu.darken.capod.troubleshooter.ui.TroubleShooterFragmentVM.BleState -import javax.inject.Inject - - -@AndroidEntryPoint -class TroubleShooterFragment : Fragment3(R.layout.troubleshooter_fragment) { - - override val vm: TroubleShooterFragmentVM by viewModels() - override val ui: TroubleshooterFragmentBinding by viewBinding() - - @Inject lateinit var webpageTool: WebpageTool - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - EdgeToEdgeHelper(requireActivity()).apply { - insetsPadding(ui.root, left = true, right = true) - insetsPadding(ui.toolbar, top = true) - insetsPadding(ui.scrollView, bottom = true) - } - ui.toolbar.apply { - setupWithNavController(findNavController()) - } - - ui.bleIntroStartAction.setOnClickListener { vm.troubleShootBle() } - - vm.bleState.observe2(ui) { state -> - bleIntroContainer.isVisible = state is BleState.Intro - bleProcessContainer.isVisible = state is BleState.Working - bleResultContainer.isVisible = state is BleState.Result - - when (state) { - is BleState.Intro -> {} - is BleState.Working -> { - bleProcessHistory.apply { - text = "" - state.allSteps.forEachIndexed { index, s -> append("#$index: $s\n") } - } - } - is BleState.Result -> { - bleResultHistory.apply { - text = "" - state.history.forEachIndexed { index, s -> append("#$index: $s\n") } - } - - - when (state) { - is BleState.Result.Success -> { - bleResultAction.isVisible = false - bleResultTitle.text = getString(R.string.troubleshooter_ble_result_success_title) - bleResultBody.text = getString(R.string.troubleshooter_ble_result_success_body) - bleResultAction.text = getString(R.string.general_close_action) - bleResultAction.setOnClickListener { popBackStack() } - } - is BleState.Result.Failure -> { - bleResultTitle.text = getString(R.string.troubleshooter_ble_result_failure_title) - bleResultBody.text = when (state.failureType) { - BleState.Result.Failure.Type.PHONE -> getString(R.string.troubleshooter_ble_result_failure_phone_body) - BleState.Result.Failure.Type.HEADPHONES -> getString(R.string.troubleshooter_ble_result_failure_phone_headphones) - } - bleResultAction.text = getString(R.string.general_check_action) - bleResultAction.setOnClickListener { vm.troubleShootBle() } - } - } - } - } - } - - super.onViewCreated(view, savedInstanceState) - } - -} diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterNavigation.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterNavigation.kt new file mode 100644 index 00000000..c84ae136 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterNavigation.kt @@ -0,0 +1,26 @@ +package eu.darken.capod.troubleshooter.ui + +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 TroubleShooterNavigation @Inject constructor() : NavigationEntry { + override fun EntryProviderScope.setup() { + entry { TroubleShooterScreenHost() } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Mod { + @Binds + @IntoSet + abstract fun bind(entry: TroubleShooterNavigation): NavigationEntry + } +} diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterScreen.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterScreen.kt new file mode 100644 index 00000000..cd5007dc --- /dev/null +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterScreen.kt @@ -0,0 +1,216 @@ +package eu.darken.capod.troubleshooter.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 TroubleShooterScreenHost(vm: TroubleShooterViewModel = hiltViewModel()) { + ErrorEventHandler(vm) + NavigationEventHandler(vm) + + val state by waitForState(vm.state) + state?.let { + TroubleShooterScreen( + state = it, + onStart = { vm.troubleShootBle() }, + onRetry = { vm.troubleShootBle() }, + onNavigateUp = { vm.navUp() }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TroubleShooterScreen( + state: TroubleShooterViewModel.State, + onStart: () -> Unit, + onRetry: () -> Unit, + onNavigateUp: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.troubleshooter_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + when (val bleState = state.bleState) { + is TroubleShooterViewModel.BleState.Intro -> { + IntroContent(onStart = onStart) + } + + is TroubleShooterViewModel.BleState.Working -> { + WorkingContent(state = bleState) + } + + is TroubleShooterViewModel.BleState.Result -> { + ResultContent(state = bleState, onRetry = onRetry) + } + } + } + } + } + } +} + +@Composable +private fun IntroContent(onStart: () -> Unit) { + Text( + text = stringResource(R.string.troubleshooter_ble_intro_title), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.troubleshooter_ble_intro_body1), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onStart, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.troubleshooter_ble_intro_start_action)) + } +} + +@Composable +private fun WorkingContent(state: TroubleShooterViewModel.BleState.Working) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator() + + Spacer(modifier = Modifier.width(8.dp)) + + Column { + Text( + text = stringResource(R.string.troubleshooter_ble_process_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.troubleshooter_ble_process_subtile), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.allSteps.mapIndexed { index, step -> "#$index: $step" }.joinToString("\n"), + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun ResultContent( + state: TroubleShooterViewModel.BleState.Result, + onRetry: () -> Unit, +) { + when (state) { + is TroubleShooterViewModel.BleState.Result.Success -> { + Text( + text = stringResource(R.string.troubleshooter_ble_result_success_title), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.troubleshooter_ble_result_success_body), + style = MaterialTheme.typography.bodyMedium, + ) + } + + is TroubleShooterViewModel.BleState.Result.Failure -> { + Text( + text = stringResource(R.string.troubleshooter_ble_result_failure_title), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = when (state.failureType) { + TroubleShooterViewModel.BleState.Result.Failure.Type.PHONE -> + stringResource(R.string.troubleshooter_ble_result_failure_phone_body) + + TroubleShooterViewModel.BleState.Result.Failure.Type.HEADPHONES -> + stringResource(R.string.troubleshooter_ble_result_failure_phone_headphones) + }, + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.general_check_action)) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.history.mapIndexed { index, step -> "#$index: $step" }.joinToString("\n"), + style = MaterialTheme.typography.bodySmall, + ) +} diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragmentVM.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt similarity index 95% rename from app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragmentVM.kt rename to app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt index 2c3bea68..3427ab0c 100644 --- a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt @@ -1,7 +1,6 @@ package eu.darken.capod.troubleshooter.ui import android.content.Context -import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.R @@ -11,7 +10,8 @@ import eu.darken.capod.common.debug.DebugSettings import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag -import eu.darken.capod.common.uix.ViewModel3 +import eu.darken.capod.common.flow.shareLatest +import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.primaryDevice @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.takeWhile @@ -31,18 +32,20 @@ import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject @HiltViewModel -class TroubleShooterFragmentVM @Inject constructor( - @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, +class TroubleShooterViewModel @Inject constructor( @ApplicationContext private val context: Context, private val dispatcherProvider: DispatcherProvider, private val generalSettings: GeneralSettings, private val profilesRepo: DeviceProfilesRepo, private val podMonitor: PodMonitor, private val debugSettings: DebugSettings, -) : ViewModel3(dispatcherProvider = dispatcherProvider) { +) : ViewModel4(dispatcherProvider) { private val _bleState = MutableStateFlow(BleState.Intro()) - val bleState = _bleState.asLiveData2() + + data class State(val bleState: BleState) + + val state = _bleState.map { State(it) }.shareLatest(scope = vmScope) init { _bleState @@ -55,7 +58,6 @@ class TroubleShooterFragmentVM @Inject constructor( if (state !is BleState.Working) { state = BleState.Working("Starting...") } - _bleState.value = state.nextStep(message) } @@ -127,7 +129,6 @@ class TroubleShooterFragmentVM @Inject constructor( } } - run { progress("Checking if we can receive BLE data at all.") if (doScan(false, false, false, true).isNotEmpty()) return@run @@ -251,11 +252,11 @@ class TroubleShooterFragmentVM @Inject constructor( fun nextStep(message: String) = this.copy( current = message, - history = history + current + history = history + current, ) fun toSuccess(message: String) = Result.Success( - history = history + message + history = history + message, ) fun toFailure(message: String, type: Result.Failure.Type) = Result.Failure( @@ -286,8 +287,8 @@ class TroubleShooterFragmentVM @Inject constructor( } companion object { - const val STEP_TIME = 10 * 1000L // 6 scans per 30 seconds max + const val STEP_TIME = 10 * 1000L - val TAG = logTag("TroubleShooter", "Fragment", "VM") + val TAG = logTag("TroubleShooter", "VM") } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/ic_baseline_check_24.xml b/app/src/main/res/drawable/ic_baseline_check_24.xml deleted file mode 100644 index e2d5ad54..00000000 --- a/app/src/main/res/drawable/ic_baseline_check_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/widget_color_swatch_bg.xml b/app/src/main/res/drawable/widget_color_swatch_bg.xml deleted file mode 100644 index 1ecb2240..00000000 --- a/app/src/main/res/drawable/widget_color_swatch_bg.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/widget_color_swatch_selected_ring.xml b/app/src/main/res/drawable/widget_color_swatch_selected_ring.xml deleted file mode 100644 index 5f40daa9..00000000 --- a/app/src/main/res/drawable/widget_color_swatch_selected_ring.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/device_manager_fragment.xml b/app/src/main/res/layout/device_manager_fragment.xml deleted file mode 100644 index 656d0b51..00000000 --- a/app/src/main/res/layout/device_manager_fragment.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/device_manager_item.xml b/app/src/main/res/layout/device_manager_item.xml deleted file mode 100644 index 7a8dfa65..00000000 --- a/app/src/main/res/layout/device_manager_item.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/device_manager_noprofiles_item.xml b/app/src/main/res/layout/device_manager_noprofiles_item.xml deleted file mode 100644 index 24bac016..00000000 --- a/app/src/main/res/layout/device_manager_noprofiles_item.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/device_manager_priority_hint_item.xml b/app/src/main/res/layout/device_manager_priority_hint_item.xml deleted file mode 100644 index a40c4976..00000000 --- a/app/src/main/res/layout/device_manager_priority_hint_item.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/device_profile_creation_fragment.xml b/app/src/main/res/layout/device_profile_creation_fragment.xml deleted file mode 100644 index 66f391a3..00000000 --- a/app/src/main/res/layout/device_profile_creation_fragment.xml +++ /dev/null @@ -1,358 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml deleted file mode 100644 index 5d456dd2..00000000 --- a/app/src/main/res/layout/main_activity.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/main_fragment.xml b/app/src/main/res/layout/main_fragment.xml deleted file mode 100644 index d015bb4c..00000000 --- a/app/src/main/res/layout/main_fragment.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/model_dropdown_item.xml b/app/src/main/res/layout/model_dropdown_item.xml deleted file mode 100644 index 28aa16c1..00000000 --- a/app/src/main/res/layout/model_dropdown_item.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/onboarding_fragment.xml b/app/src/main/res/layout/onboarding_fragment.xml deleted file mode 100644 index f1991009..00000000 --- a/app/src/main/res/layout/onboarding_fragment.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/overview_bluetooth_disabled_item.xml b/app/src/main/res/layout/overview_bluetooth_disabled_item.xml deleted file mode 100644 index 53591808..00000000 --- a/app/src/main/res/layout/overview_bluetooth_disabled_item.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/overview_monitoring_active_item.xml b/app/src/main/res/layout/overview_monitoring_active_item.xml deleted file mode 100644 index a33d7ddb..00000000 --- a/app/src/main/res/layout/overview_monitoring_active_item.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/overview_noprofiles_item.xml b/app/src/main/res/layout/overview_noprofiles_item.xml deleted file mode 100644 index 2ac3a942..00000000 --- a/app/src/main/res/layout/overview_noprofiles_item.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/overview_permission_item.xml b/app/src/main/res/layout/overview_permission_item.xml deleted file mode 100644 index 84b9f528..00000000 --- a/app/src/main/res/layout/overview_permission_item.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/overview_pods_dual_item.xml b/app/src/main/res/layout/overview_pods_dual_item.xml deleted file mode 100644 index b05c291c..00000000 --- a/app/src/main/res/layout/overview_pods_dual_item.xml +++ /dev/null @@ -1,474 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/overview_pods_single_item.xml b/app/src/main/res/layout/overview_pods_single_item.xml deleted file mode 100644 index 4fe32422..00000000 --- a/app/src/main/res/layout/overview_pods_single_item.xml +++ /dev/null @@ -1,228 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/overview_pods_unknown_item.xml b/app/src/main/res/layout/overview_pods_unknown_item.xml deleted file mode 100644 index 24f5a239..00000000 --- a/app/src/main/res/layout/overview_pods_unknown_item.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/overview_unmatched_devices_item.xml b/app/src/main/res/layout/overview_unmatched_devices_item.xml deleted file mode 100644 index d76f6359..00000000 --- a/app/src/main/res/layout/overview_unmatched_devices_item.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/paired_device_dropdown_item.xml b/app/src/main/res/layout/paired_device_dropdown_item.xml deleted file mode 100644 index 17bce5ec..00000000 --- a/app/src/main/res/layout/paired_device_dropdown_item.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/preference_material_switch.xml b/app/src/main/res/layout/preference_material_switch.xml deleted file mode 100644 index 764709d2..00000000 --- a/app/src/main/res/layout/preference_material_switch.xml +++ /dev/null @@ -1,8 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml deleted file mode 100644 index 96b62476..00000000 --- a/app/src/main/res/layout/settings_fragment.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/troubleshooter_fragment.xml b/app/src/main/res/layout/troubleshooter_fragment.xml deleted file mode 100644 index e3d45afc..00000000 --- a/app/src/main/res/layout/troubleshooter_fragment.xml +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/widget_color_swatch_item.xml b/app/src/main/res/layout/widget_color_swatch_item.xml deleted file mode 100644 index fa2531eb..00000000 --- a/app/src/main/res/layout/widget_color_swatch_item.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/widget_configuration_activity.xml b/app/src/main/res/layout/widget_configuration_activity.xml deleted file mode 100644 index 698577e5..00000000 --- a/app/src/main/res/layout/widget_configuration_activity.xml +++ /dev/null @@ -1,300 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/widget_configuration_profile_item.xml b/app/src/main/res/layout/widget_configuration_profile_item.xml deleted file mode 100644 index cfef1151..00000000 --- a/app/src/main/res/layout/widget_configuration_profile_item.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/device_profile_creation_menu.xml b/app/src/main/res/menu/device_profile_creation_menu.xml deleted file mode 100644 index 187e1792..00000000 --- a/app/src/main/res/menu/device_profile_creation_menu.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml deleted file mode 100644 index 466c134d..00000000 --- a/app/src/main/res/menu/main.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_settings_index.xml b/app/src/main/res/menu/menu_settings_index.xml deleted file mode 100644 index 1d971d80..00000000 --- a/app/src/main/res/menu/menu_settings_index.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index d9031ffa..00000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b917b02c..4878668c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,7 @@ Add profile Create Profile Profile name + Name can\'t be empty My Headphones Device model Paired device diff --git a/app/src/main/res/xml/preferences_acknowledgements.xml b/app/src/main/res/xml/preferences_acknowledgements.xml deleted file mode 100644 index 27b98909..00000000 --- a/app/src/main/res/xml/preferences_acknowledgements.xml +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_debug.xml b/app/src/main/res/xml/preferences_debug.xml deleted file mode 100644 index d7ade8c3..00000000 --- a/app/src/main/res/xml/preferences_debug.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml deleted file mode 100644 index 04d19334..00000000 --- a/app/src/main/res/xml/preferences_general.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_index.xml b/app/src/main/res/xml/preferences_index.xml deleted file mode 100644 index 7d0a3646..00000000 --- a/app/src/main/res/xml/preferences_index.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_reactions.xml b/app/src/main/res/xml/preferences_reactions.xml deleted file mode 100644 index dde0eca1..00000000 --- a/app/src/main/res/xml/preferences_reactions.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/preferences_support.xml b/app/src/main/res/xml/preferences_support.xml deleted file mode 100644 index 97f89055..00000000 --- a/app/src/main/res/xml/preferences_support.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt index 7a589158..6b615422 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt @@ -6,7 +6,6 @@ import eu.darken.capod.pods.core.apple.airpods.AirPodsPro import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice import io.kotest.matchers.shouldBe import io.kotest.matchers.types.instanceOf -import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test @@ -34,7 +33,7 @@ class AirPodsFactoryTest : BaseAirPodsTest() { } @Test - fun `invalid data`() = runBlockingTest { + fun `invalid data`() = runTest { create("abcd") { this shouldBe null } diff --git a/build.gradle.kts b/build.gradle.kts index 6044ec20..63e2a759 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,6 @@ buildscript { classpath("com.android.tools.build:gradle:9.0.1") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.Kotlin.core}") classpath("com.google.dagger:hilt-android-gradle-plugin:${Versions.Dagger.core}") - classpath("androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.AndroidX.Navigation.core}") } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 0095ea0d..58681c7a 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -20,5 +20,7 @@ repositories { dependencies { implementation("com.android.tools.build:gradle:9.0.1") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10") + implementation("org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.2.10") + implementation("org.jetbrains.kotlin:kotlin-serialization:2.2.10") implementation("com.squareup:javapoet:1.13.0") } diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 19baedb5..ff244b96 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -71,9 +71,7 @@ fun DependencyHandlerScope.addOkio() { } fun DependencyHandlerScope.addNavigation() { - implementation("androidx.navigation:navigation-fragment-ktx:${Versions.AndroidX.Navigation.core}") - implementation("androidx.navigation:navigation-ui-ktx:${Versions.AndroidX.Navigation.core}") - androidTestImplementation("androidx.navigation:navigation-testing:${Versions.AndroidX.Navigation.core}") + implementation("androidx.navigation:navigation-common:${Versions.AndroidX.Navigation.core}") } fun DependencyHandlerScope.addBaseAndroid() { @@ -84,7 +82,7 @@ fun DependencyHandlerScope.addBaseAndroid() { } fun DependencyHandlerScope.addBaseAndroidUi() { - implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.constraintlayout:constraintlayout:2.1.3") implementation("androidx.activity:activity-ktx:1.8.0") @@ -100,6 +98,38 @@ fun DependencyHandlerScope.addBaseAndroidUi() { implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVers") } +fun DependencyHandlerScope.addCompose() { + val composeBom = platform("androidx.compose:compose-bom:${Versions.Compose.bom}") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + implementation("androidx.activity:activity-compose:1.12.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5") + + implementation("androidx.hilt:hilt-navigation-compose:1.3.0-alpha01") +} + +fun DependencyHandlerScope.addNavigation3() { + implementation("androidx.navigation3:navigation3-runtime:${Versions.Navigation3.core}") + implementation("androidx.navigation3:navigation3-ui:${Versions.Navigation3.core}") + + implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0") + + implementation("androidx.compose.material3.adaptive:adaptive-navigation3:1.3.0-alpha06") +} + +fun DependencyHandlerScope.addSerialization() { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:${Versions.Serialization.core}") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.Serialization.core}") +} + fun DependencyHandlerScope.addTesting() { testImplementation("junit:junit:4.13.2") diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt index 9da2556f..5854e194 100644 --- a/buildSrc/src/main/java/Versions.kt +++ b/buildSrc/src/main/java/Versions.kt @@ -1,6 +1,6 @@ object Versions { object Kotlin { - const val core = "2.1.20" + const val core = "2.2.10" const val coroutines = "1.8.0" } @@ -13,4 +13,16 @@ object Versions { const val core = "2.9.3" } } + + object Compose { + const val bom = "2025.06.01" + } + + object Navigation3 { + const val core = "1.0.0" + } + + object Serialization { + const val core = "1.9.0" + } }