diff --git a/app/src/main/java/com/mapgie/dash/ui/components/PinWidgetChooserDialog.kt b/app/src/main/java/com/mapgie/dash/ui/components/PinWidgetChooserDialog.kt new file mode 100644 index 0000000..d399ed8 --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/ui/components/PinWidgetChooserDialog.kt @@ -0,0 +1,44 @@ +package com.mapgie.dash.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics + +/** + * Shown when pinning a task/chore while 2+ Pinned Item widgets are placed, since a + * single tap can no longer unambiguously target "the" widget. Widgets have no + * user-given label yet (no config screen), so they're offered in placement order. + */ +@Composable +fun PinWidgetChooserDialog( + widgetIds: List, + onChoose: (appWidgetId: Int) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Pin to which widget?") }, + text = { + Column { + widgetIds.forEachIndexed { index, appWidgetId -> + TextButton( + onClick = { onChoose(appWidgetId) }, + modifier = Modifier.semantics { role = Role.Button } + ) { + Text("Pinned Widget ${index + 1}") + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt index d4abd4f..b888a04 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt @@ -33,6 +33,7 @@ import com.mapgie.dash.ui.components.AddReminderSheet import com.mapgie.dash.ui.components.ChoreCard import com.mapgie.dash.ui.components.ChoreOverviewSheet import com.mapgie.dash.ui.components.EditChoreSheet +import com.mapgie.dash.ui.components.PinWidgetChooserDialog import com.mapgie.dash.ui.components.WriteTagDialog import kotlinx.coroutines.launch @@ -473,6 +474,14 @@ fun ChoreListScreen( ) } + uiState.pinChooser?.let { chooser -> + PinWidgetChooserDialog( + widgetIds = chooser.widgetIds, + onChoose = { appWidgetId -> viewModel.pinToWidget(appWidgetId) }, + onDismiss = { viewModel.dismissPinChooser() } + ) + } + } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt index b54aa46..86fee1a 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt @@ -13,6 +13,7 @@ import com.mapgie.dash.data.model.remindAtInstant import com.mapgie.dash.data.preferences.SettingsRepository import com.mapgie.dash.data.repository.ChoreRepository import com.mapgie.dash.data.repository.ReminderRepository +import com.mapgie.dash.widget.PinChooserState import com.mapgie.dash.widget.PinnedItemStore import com.mapgie.dash.widget.PinnedItemType import com.mapgie.dash.widget.PinnedWidgetItem @@ -58,7 +59,8 @@ data class ChoreUiState( val pendingNfcTagId: String? = null, val recentScan: RecentScan? = null, val pinnedChoreId: String? = null, - val scanHistory: List = emptyList() + val scanHistory: List = emptyList(), + val pinChooser: PinChooserState? = null ) { private val ownerFiltered: List get() { @@ -168,12 +170,35 @@ class ChoreListViewModel @Inject constructor( } fun togglePin(choreId: String) { + val item = PinnedWidgetItem(PinnedItemType.CHORE, choreId) viewModelScope.launch { - pinnedItemStore.togglePinned(PinnedWidgetItem(PinnedItemType.CHORE, choreId)) + val placedWidgetIds = pinnedItemStore.placedAppWidgetIds() + if (placedWidgetIds.size > 1) { + _uiState.update { it.copy(pinChooser = PinChooserState(item, placedWidgetIds)) } + return@launch + } + pinnedItemStore.togglePinned(item) + // With at most one widget placed, that widget should always track the default + // pin, not a stale per-instance override left over from when more were placed. + placedWidgetIds.singleOrNull()?.let { pinnedItemStore.setPinnedFor(it, null) } + WidgetUpdater.updateAll(appContext) + } + } + + /** Commits the pin to a specific widget instance chosen from [ChoreUiState.pinChooser]. */ + fun pinToWidget(appWidgetId: Int) { + val chooser = _uiState.value.pinChooser ?: return + _uiState.update { it.copy(pinChooser = null) } + viewModelScope.launch { + pinnedItemStore.togglePinnedFor(appWidgetId, chooser.item) WidgetUpdater.updateAll(appContext) } } + fun dismissPinChooser() { + _uiState.update { it.copy(pinChooser = null) } + } + fun load() { viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null) } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt index d342e88..e1a4e08 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt @@ -56,6 +56,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.mapgie.dash.data.model.AddMenuOption import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.ui.components.EditTaskSheet +import com.mapgie.dash.ui.components.PinWidgetChooserDialog import com.mapgie.dash.ui.components.TaskCard import com.mapgie.dash.ui.components.TaskOverviewSheet @@ -317,6 +318,14 @@ fun TaskListScreen( onDismiss = { showOverviewSheet = false } ) } + + uiState.pinChooser?.let { chooser -> + PinWidgetChooserDialog( + widgetIds = chooser.widgetIds, + onChoose = { appWidgetId -> viewModel.pinToWidget(appWidgetId) }, + onDismiss = { viewModel.dismissPinChooser() } + ) + } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt index 1b3d4f2..c0c5508 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt @@ -19,6 +19,7 @@ import com.mapgie.dash.data.model.urgency import com.mapgie.dash.data.preferences.SettingsRepository import com.mapgie.dash.data.repository.ReminderRepository import com.mapgie.dash.data.repository.TaskRepository +import com.mapgie.dash.widget.PinChooserState import com.mapgie.dash.widget.PinnedItemStore import com.mapgie.dash.widget.PinnedItemType import com.mapgie.dash.widget.PinnedWidgetItem @@ -53,6 +54,7 @@ data class TaskUiState( val hideThresholdDays: Int = -1, val zenMode: Boolean = false, val zenSortAscending: Boolean = true, + val pinChooser: PinChooserState? = null, ) { val displayed: List get() { @@ -146,12 +148,35 @@ class TaskListViewModel @Inject constructor( } fun togglePin(taskId: String) { + val item = PinnedWidgetItem(PinnedItemType.TASK, taskId) viewModelScope.launch { - pinnedItemStore.togglePinned(PinnedWidgetItem(PinnedItemType.TASK, taskId)) + val placedWidgetIds = pinnedItemStore.placedAppWidgetIds() + if (placedWidgetIds.size > 1) { + _uiState.update { it.copy(pinChooser = PinChooserState(item, placedWidgetIds)) } + return@launch + } + pinnedItemStore.togglePinned(item) + // With at most one widget placed, that widget should always track the default + // pin, not a stale per-instance override left over from when more were placed. + placedWidgetIds.singleOrNull()?.let { pinnedItemStore.setPinnedFor(it, null) } + WidgetUpdater.updateAll(appContext) + } + } + + /** Commits the pin to a specific widget instance chosen from [TaskUiState.pinChooser]. */ + fun pinToWidget(appWidgetId: Int) { + val chooser = _uiState.value.pinChooser ?: return + _uiState.update { it.copy(pinChooser = null) } + viewModelScope.launch { + pinnedItemStore.togglePinnedFor(appWidgetId, chooser.item) WidgetUpdater.updateAll(appContext) } } + fun dismissPinChooser() { + _uiState.update { it.copy(pinChooser = null) } + } + fun load() { viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null) } diff --git a/app/src/main/java/com/mapgie/dash/widget/PinnedItemStore.kt b/app/src/main/java/com/mapgie/dash/widget/PinnedItemStore.kt index d4e7d40..3db8cc3 100644 --- a/app/src/main/java/com/mapgie/dash/widget/PinnedItemStore.kt +++ b/app/src/main/java/com/mapgie/dash/widget/PinnedItemStore.kt @@ -1,5 +1,7 @@ package com.mapgie.dash.widget +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences @@ -8,10 +10,12 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -22,9 +26,18 @@ enum class PinnedItemType { TASK, CHORE } data class PinnedWidgetItem(val type: PinnedItemType, val id: String) +/** Pending "which widget?" choice, shown when pinning while 2+ instances are placed. */ +data class PinChooserState(val item: PinnedWidgetItem, val widgetIds: List) + /** - * Stores the single task or chore the user has chosen to "pin" to the - * Pinned Item widget via the pin action on task/chore cards. + * Stores the task or chore the user has chosen to "pin" to the Pinned Item + * widget via the pin action on task/chore cards. + * + * There's a single default pin (unchanged from before), which is what the + * in-app pin icon reflects and what any widget instance shows unless it has + * its own override. When a user has 2+ Pinned Item widgets placed, a specific + * instance can be given its own override via the `...For(appWidgetId, ...)` + * methods, so each placed widget can show something different. */ @Singleton class PinnedItemStore @Inject constructor( @@ -35,28 +48,73 @@ class PinnedItemStore @Inject constructor( val ID = stringPreferencesKey("pinned_item_id") } + private fun typeKey(appWidgetId: Int) = stringPreferencesKey("pinned_item_type_$appWidgetId") + private fun idKey(appWidgetId: Int) = stringPreferencesKey("pinned_item_id_$appWidgetId") + + /** The default pin: shown by the in-app pin icon and by any widget instance with no override. */ val pinnedItem: Flow = context.widgetPrefsDataStore.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .map { prefs -> readItem(prefs, Keys.TYPE, Keys.ID) } + + /** What widget instance [appWidgetId] should show: its own override, falling back to the default. */ + fun pinnedItemFor(appWidgetId: Int): Flow = context.widgetPrefsDataStore.data .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } .map { prefs -> - val type = prefs[Keys.TYPE]?.let { runCatching { PinnedItemType.valueOf(it) }.getOrNull() } - val id = prefs[Keys.ID] - if (type != null && id != null) PinnedWidgetItem(type, id) else null + readItem(prefs, typeKey(appWidgetId), idKey(appWidgetId)) ?: readItem(prefs, Keys.TYPE, Keys.ID) } + private fun readItem( + prefs: Preferences, + typeKey: Preferences.Key, + idKey: Preferences.Key + ): PinnedWidgetItem? { + val type = prefs[typeKey]?.let { runCatching { PinnedItemType.valueOf(it) }.getOrNull() } + val id = prefs[idKey] + return if (type != null && id != null) PinnedWidgetItem(type, id) else null + } + + /** appWidgetId of every Pinned Item widget instance currently on a home screen, in a stable order. */ + suspend fun placedAppWidgetIds(): List = withContext(Dispatchers.IO) { + val manager = AppWidgetManager.getInstance(context) ?: return@withContext emptyList() + val component = ComponentName(context, PinnedItemWidgetReceiver::class.java) + manager.getAppWidgetIds(component).sorted() + } + + /** Sets the default pin. */ suspend fun setPinned(item: PinnedWidgetItem?) { + writePinned(Keys.TYPE, Keys.ID, item) + } + + /** Sets the override for widget instance [appWidgetId] specifically. */ + suspend fun setPinnedFor(appWidgetId: Int, item: PinnedWidgetItem?) { + writePinned(typeKey(appWidgetId), idKey(appWidgetId), item) + } + + private suspend fun writePinned( + typeKey: Preferences.Key, + idKey: Preferences.Key, + item: PinnedWidgetItem? + ) { context.widgetPrefsDataStore.edit { prefs -> if (item == null) { - prefs.remove(Keys.TYPE) - prefs.remove(Keys.ID) + prefs.remove(typeKey) + prefs.remove(idKey) } else { - prefs[Keys.TYPE] = item.type.name - prefs[Keys.ID] = item.id + prefs[typeKey] = item.type.name + prefs[idKey] = item.id } } } + /** Toggles the default pin. */ suspend fun togglePinned(item: PinnedWidgetItem) { val current = pinnedItem.first() setPinned(if (current == item) null else item) } + + /** Toggles the override for widget instance [appWidgetId] specifically. */ + suspend fun togglePinnedFor(appWidgetId: Int, item: PinnedWidgetItem) { + val current = pinnedItemFor(appWidgetId).first() + setPinnedFor(appWidgetId, if (current == item) null else item) + } } diff --git a/app/src/main/java/com/mapgie/dash/widget/PinnedItemWidget.kt b/app/src/main/java/com/mapgie/dash/widget/PinnedItemWidget.kt index 229116d..8c163c6 100644 --- a/app/src/main/java/com/mapgie/dash/widget/PinnedItemWidget.kt +++ b/app/src/main/java/com/mapgie/dash/widget/PinnedItemWidget.kt @@ -13,6 +13,7 @@ import androidx.glance.action.clickable import androidx.glance.Button import androidx.glance.appwidget.CheckBox import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionRunCallback @@ -56,7 +57,8 @@ class PinnedItemWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val entryPoint = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) - val data = loadPinnedData(entryPoint) + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val data = loadPinnedData(entryPoint, appWidgetId) provideContent { GlanceTheme(colors = DashGlanceTheme.colors) { PinnedContent(data) @@ -64,8 +66,8 @@ class PinnedItemWidget : GlanceAppWidget() { } } - private suspend fun loadPinnedData(entryPoint: WidgetEntryPoint): PinnedData { - val pinned = entryPoint.pinnedItemStore().pinnedItem.first() ?: return PinnedData.NotSet + private suspend fun loadPinnedData(entryPoint: WidgetEntryPoint, appWidgetId: Int): PinnedData { + val pinned = entryPoint.pinnedItemStore().pinnedItemFor(appWidgetId).first() ?: return PinnedData.NotSet val settings = entryPoint.settingsRepository().settings.first() if (settings.supabaseUrl.isBlank() || settings.supabaseKey.isBlank()) { diff --git a/changelog/unreleased/widget-per-instance-pinning.json b/changelog/unreleased/widget-per-instance-pinning.json new file mode 100644 index 0000000..44596d2 --- /dev/null +++ b/changelog/unreleased/widget-per-instance-pinning.json @@ -0,0 +1,6 @@ +{ + "bump": "minor", + "added": [ + "Multiple Pinned Item widgets placed on the same home screen can now each pin a different task or chore, with a chooser shown when pinning while 2+ are placed" + ] +}