Implemented widget configuration for multiple profiles

This commit is contained in:
darken
2025-09-29 19:29:43 +02:00
committed by Matthias Urhahn
parent ad7089ea7c
commit 49fc0f018a
14 changed files with 583 additions and 43 deletions
+10
View File
@@ -97,6 +97,16 @@
android:resource="@xml/file_provider_paths" />
</provider>
<!-- Widget configuration -->
<activity
android:name=".main.ui.widget.WidgetConfigurationActivity"
android:theme="@style/AppTheme"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- Debug stuff-->
<activity
android:name=".common.debug.recording.ui.RecorderActivity"
@@ -0,0 +1,119 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.EdgeToEdgeHelper
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
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
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Set result to CANCELED in case user backs out
setResult(RESULT_CANCELED)
// Get widget ID from intent
widgetId = intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
)
log(TAG) { "onCreate(widgetId=$widgetId)" }
// If widget ID is invalid, finish
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID, finishing" }
finish()
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
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
}
}
}
private fun confirmSelection() {
vm.confirmSelection()
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
setResult(RESULT_OK, resultValue)
val appWidgetManager = AppWidgetManager.getInstance(this@WidgetConfigurationActivity)
WidgetProvider.updateWidget(
context = this@WidgetConfigurationActivity,
appWidgetManager = appWidgetManager,
widgetId = widgetId
)
finish()
}
companion object {
private val TAG = logTag("Widget", "ConfigurationActivity")
}
}
@@ -0,0 +1,79 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import androidx.lifecycle.SavedStateHandle
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.combine
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@HiltViewModel
class WidgetConfigurationViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val deviceProfilesRepo: DeviceProfilesRepo,
private val widgetSettings: WidgetSettings,
private val upgradeRepo: UpgradeRepo,
) : ViewModel3(dispatcherProvider) {
val widgetId: Int
get() = savedStateHandle.get<Int>(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID
init {
log(TAG) { "ViewModel init(widgetId=$widgetId)" }
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID" }
}
}
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
val state = combine(
selectedProfile,
deviceProfilesRepo.profiles,
upgradeRepo.upgradeInfo,
) { selected, profiles, upgradeInfo ->
log(TAG) { "loadProfiles()" }
State(
profiles = profiles,
isPro = upgradeInfo.isPro,
selectedProfile = selected,
)
}.asLiveData2()
data class State(
val profiles: List<DeviceProfile> = emptyList(),
val selectedProfile: ProfileId? = null,
val isPro: Boolean = false,
) {
val canConfirm: Boolean = selectedProfile != null
}
fun selectProfile(profileId: ProfileId) {
log(TAG) { "selectProfile(profileId=$profileId)" }
selectedProfile.value = profileId
}
fun confirmSelection() {
val selectedProfile = selectedProfile.value
if (selectedProfile != null) {
log(TAG) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
}
}
companion object {
private val TAG = logTag("Widget", "ConfigurationVM")
}
}
@@ -0,0 +1,35 @@
package eu.darken.capod.main.ui.widget
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.AsyncDiffer
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.differ.HasAsyncDiffer
import eu.darken.capod.common.lists.differ.setupDiffer
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import javax.inject.Inject
class WidgetProfileSelectionAdapter @Inject constructor() :
ModularAdapter<WidgetProfileSelectionAdapter.BaseVH<WidgetProfileSelectionAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<WidgetProfileSelectionAdapter.Item> {
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
init {
modules.add(DataBinderMod(data))
modules.add(TypedVHCreatorMod({ data[it] is WidgetProfileSelectionVH.Item }) { WidgetProfileSelectionVH(it) })
}
override fun getItemCount(): Int = data.size
abstract class BaseVH<D : Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : VH(layoutId, parent), BindableVH<D, B>
interface Item : DifferItem
}
@@ -0,0 +1,46 @@
package eu.darken.capod.main.ui.widget
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.WidgetConfigurationProfileItemBinding
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfile
class WidgetProfileSelectionVH(parent: ViewGroup) :
WidgetProfileSelectionAdapter.BaseVH<WidgetProfileSelectionVH.Item, WidgetConfigurationProfileItemBinding>(
R.layout.widget_configuration_profile_item,
parent
) {
override val viewBinding = lazy {
WidgetConfigurationProfileItemBinding.bind(itemView)
}
override val onBindData: WidgetConfigurationProfileItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
val profile = item.profile
profileName.text = profile.label
val modelText = when (profile.model) {
PodDevice.Model.UNKNOWN -> context.getString(R.string.pods_unknown_label)
else -> profile.model.label
}
profileModel.text = modelText
profileIcon.setImageResource(profile.model.iconRes)
profileRadio.isChecked = item.isSelected
root.isChecked = item.isSelected
root.setOnClickListener { item.onProfileClick(profile) }
}
data class Item(
val profile: DeviceProfile,
val isSelected: Boolean,
val onProfileClick: (DeviceProfile) -> Unit,
) : WidgetProfileSelectionAdapter.Item {
override val stableId: Long = profile.id.hashCode().toLong()
}
}
@@ -35,6 +35,7 @@ import eu.darken.capod.pods.core.getBatteryLevelCase
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
import eu.darken.capod.pods.core.getBatteryLevelRightPod
import eu.darken.capod.profiles.core.ProfileId
import finish2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -50,6 +51,7 @@ class WidgetProvider : AppWidgetProvider() {
@Inject lateinit var podDeviceCache: PodDeviceCache
@Inject lateinit var podFactory: PodFactory
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var widgetSettings: WidgetSettings
@AppScope @Inject lateinit var appScope: CoroutineScope
private fun executeAsync(
@@ -97,6 +99,15 @@ class WidgetProvider : AppWidgetProvider() {
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
log(TAG) { "onDeleted(appWidgetIds=${appWidgetIds.toList()})" }
executeAsync("onDeleted") {
appWidgetIds.forEach { widgetId ->
widgetSettings.removeWidget(widgetId)
}
}
}
/**
* Returns number of cells needed for given size of the widget.
*
@@ -124,9 +135,10 @@ class WidgetProvider : AppWidgetProvider() {
widgetId: Int,
options: Bundle?
) {
log(TAG) { "updateWidget(widgetId=$widgetId, options=$options)" }
val profileId: ProfileId? = widgetSettings.getWidgetProfile(widgetId)
log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" }
val device: PodDevice? = podMonitor.latestMainDevice()
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context)
@@ -141,15 +153,16 @@ class WidgetProvider : AppWidgetProvider() {
* added if these restrictions will ever be loosened in the future.
*/
val layout = when (columns) {
in 1 .. 4 -> R.layout.widget_pod_dual_compact_layout
in 1..4 -> R.layout.widget_pod_dual_compact_layout
else -> R.layout.widget_pod_dual_wide_layout
}
createDualPodLayout(context, device, layout)
}
device is SinglePodDevice -> createSinglePodLayout(context, device)
device is PodDevice -> createUnknownPodLayout(context, device)
else -> createNoDeviceLayout(context)
else -> createNoDeviceLayout(context, profileId != null)
}
widgetManager.updateAppWidget(widgetId, layout)
}
@@ -191,8 +204,9 @@ class WidgetProvider : AppWidgetProvider() {
private fun createNoDeviceLayout(
context: Context,
hasConfiguredProfile: Boolean = false
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context)" }
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
@@ -202,7 +216,12 @@ class WidgetProvider : AppWidgetProvider() {
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.overview_nomaindevice_label))
val messageRes = if (hasConfiguredProfile) {
R.string.widget_no_data_label
} else {
R.string.overview_nomaindevice_label
}
setTextViewText(R.id.primary, context.getString(messageRes))
}
private fun createDualPodLayout(
@@ -286,5 +305,13 @@ class WidgetProvider : AppWidgetProvider() {
companion object {
val TAG = logTag("Widget", "Provider")
fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, widgetId: Int) {
val intent = Intent(context, WidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
}
context.sendBroadcast(intent)
}
}
}
@@ -0,0 +1,50 @@
package eu.darken.capod.main.ui.widget
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
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.profiles.core.ProfileId
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WidgetSettings @Inject constructor(
@ApplicationContext private val context: Context
) {
private val preferences: SharedPreferences = context.getSharedPreferences(
"widget_preferences",
Context.MODE_PRIVATE
)
fun saveWidgetProfile(widgetId: Int, profileId: ProfileId) {
log(TAG, VERBOSE) { "saveWidgetProfile(widgetId=$widgetId, profileId=$profileId)" }
preferences.edit {
putString(getWidgetProfileKey(widgetId), profileId)
}
}
fun getWidgetProfile(widgetId: Int): ProfileId? {
val profileId = preferences.getString(getWidgetProfileKey(widgetId), null)
log(TAG, VERBOSE) { "getWidgetProfile(widgetId=$widgetId) = $profileId" }
return profileId
}
fun removeWidget(widgetId: Int) {
log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" }
preferences.edit {
remove(getWidgetProfileKey(widgetId))
}
}
private fun getWidgetProfileKey(widgetId: Int): String = "$WIDGET_PROFILE_PREFIX$widgetId"
companion object {
private const val WIDGET_PROFILE_PREFIX = "widget_profile_"
private val TAG = logTag("Widget", "Settings")
}
}
@@ -28,47 +28,54 @@ class PodDeviceCache @Inject constructor(
private val cacheDir by lazy {
File(context.cacheDir, "device_cache").apply { mkdirs() }
}
private val mainDeviceCacheFile = File(cacheDir, "main_device.raw")
private val jsonAdapter = moshi.adapter<BleScanResult>()
private val lock = Mutex()
suspend fun saveMainDevice(device: BleScanResult?) = withContext(dispatcherProvider.IO) {
log(TAG, VERBOSE) { "saveMainDevice(device=$device)" }
lock.withLock {
try {
if (device == null) {
mainDeviceCacheFile.delete()
} else {
val json = jsonAdapter.toJson(device)
mainDeviceCacheFile.writeText(json)
}
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to save $device:${e.asLog()}" }
}
}
}
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
suspend fun loadMainDevice(): BleScanResult? = withContext(dispatcherProvider.IO) {
log(TAG, VERBOSE) { "loadMainDevice()" }
suspend fun load(id: ProfileId): BleScanResult? = withContext(dispatcherProvider.IO) {
log(TAG, VERBOSE) { "load(id=$id)" }
val cacheFile = id.toCacheFile()
lock.withLock {
if (!mainDeviceCacheFile.exists()) return@withLock null
if (!cacheFile.exists()) return@withLock null
try {
val raw = mainDeviceCacheFile.readText()
val raw = cacheFile.readText()
jsonAdapter.fromJson(raw)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to read main-device:${e.asLog()}" }
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
cacheFile.delete()
null
}
}
}
fun load(id: ProfileId): BleScanResult? {
log(TAG, VERBOSE) { "load(): $id" }
return null
suspend fun saveAll(data: Map<ProfileId, BleScanResult>) {
log(TAG, VERBOSE) { "saveAll(): ${data.size} entries" }
lock.withLock {
data.forEach { (id, device) ->
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
val cacheFile = id.toCacheFile()
try {
val json = jsonAdapter.toJson(device)
cacheFile.writeText(json)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
cacheFile.delete()
}
}
}
}
fun save(id: ProfileId) {
log(TAG, VERBOSE) { "save(): $id" }
suspend fun delete(id: ProfileId) {
log(TAG, VERBOSE) { "delete(): profileId=$id" }
lock.withLock {
val cacheFile = id.toCacheFile()
try {
cacheFile.delete()
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to delete profile for $id: ${e.asLog()}" }
}
}
}
companion object {
@@ -72,7 +72,7 @@ class PodMonitor @Inject constructor(
}
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
.map { processWithCache(it).values }
.flatMapLatest { devices ->
.flatMapLatest { devices ->
flowOf(sortPodsToInterest(devices))
}
.retryWhen { cause, attempt ->
@@ -86,7 +86,7 @@ class PodMonitor @Inject constructor(
private suspend fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
val now = Instant.now()
val profiles = profilesRepo.currentProfiles() ?: emptyList()
return devices.sortedWith(
compareBy<PodDevice> { device ->
// Use profile position in list as priority (0 = highest priority)
@@ -178,17 +178,33 @@ class PodMonitor @Inject constructor(
deviceCache[it.identifier] = it
pods[it.identifier] = it
}
newPods
.mapNotNull {
val profileId = it.device.meta.profile?.id ?: return@mapNotNull null
profileId to it.device.scanResult
}
.toMap()
.run { podDeviceCache.saveAll(this) }
return pods
}
suspend fun latestMainDevice(): PodDevice? {
val currentMain = devices.firstOrNull()?.firstOrNull()
log(TAG) { "Live mainDevice is $currentMain" }
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
return currentMain ?: profilesRepo.currentProfiles().firstOrNull()
?.let { podDeviceCache.load(it.id) }
?.let { podFactory.createPod(it)?.device }
.also { log(TAG) { "Cached mainDevice is $it" } }
val liveDevice = devices.firstOrNull()?.firstOrNull { device ->
device.meta.profile?.id == profileId
}
if (liveDevice != null) {
log(TAG) { "Found live device for profile $profileId: $liveDevice" }
return liveDevice
}
val cachedDevice = podDeviceCache.load(profileId)?.let {
podFactory.createPod(it)?.device
}
log(TAG) { "Cached device for profile $profileId: $cachedDevice" }
return cachedDevice
}
companion object {
@@ -8,6 +8,7 @@ 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.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodDeviceCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -23,6 +24,7 @@ class DeviceProfilesRepo @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val settings: DeviceProfilesSettings,
private val podDeviceCache: PodDeviceCache,
) {
private val mutex = Mutex()
@@ -74,11 +76,12 @@ class DeviceProfilesRepo @Inject constructor(
log(VERBOSE) { "Updated device profile: ${profile.label}" }
}
suspend fun removeProfile(profileId: String) = mutex.withLock {
suspend fun removeProfile(profileId: ProfileId) = mutex.withLock {
val currentContainer = settings.profiles.value
val updatedProfiles = currentContainer.profiles.filter { it.id != profileId }
settings.profiles.value = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Removed device profile with ID: $profileId" }
podDeviceCache.delete(profileId)
}
suspend fun reorderProfiles(profiles: List<DeviceProfile>) = mutex.withLock {
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/content_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="32dp"
android:layout_weight="1"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_configuration_title"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge" />
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:text="@string/widget_configuration_description"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profiles_recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/widget_configuration_profile_item" />
</LinearLayout>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pro_required_caption"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="32dp"
android:text="@string/common_feature_requires_pro_msg"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorOnSurfaceVariant"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/buttons_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="32dp"
android:gravity="end"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/cancel_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@android:string/cancel" />
<com.google.android.material.button.MaterialButton
android:id="@+id/confirm_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@android:string/ok" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="4dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:id="@+id/profile_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="16dp"
tools:src="@drawable/devic_airpods_gen1_both" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/profile_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textStyle="bold"
tools:text="AirPods Pro" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/profile_model"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
tools:text="AirPods Pro (2nd generation)" />
</LinearLayout>
<RadioButton
android:id="@+id/profile_radio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:clickable="false"
android:focusable="false" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+5
View File
@@ -114,6 +114,11 @@
<string name="translators_thanks_description">darken</string>
<string name="widget_description">A widget showing the last known device status.</string>
<string name="widget_configuration_title">Select Device</string>
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
<string name="common_feature_requires_pro_msg">This feature requires CAPod Pro.</string>
<string name="widget_no_data_label">No data</string>
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
+2 -1
View File
@@ -11,5 +11,6 @@
android:initialLayout="@layout/widget_loading_layout"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetFeatures="reconfigurable"
android:configure="eu.darken.capod.main.ui.widget.WidgetConfigurationActivity"
tools:targetApi="s" />