Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Int>,
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") }
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,7 +59,8 @@ data class ChoreUiState(
val pendingNfcTagId: String? = null,
val recentScan: RecentScan? = null,
val pinnedChoreId: String? = null,
val scanHistory: List<ScanDto> = emptyList()
val scanHistory: List<ScanDto> = emptyList(),
val pinChooser: PinChooserState? = null
) {
private val ownerFiltered: List<Chore>
get() {
Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TaskDto>
get() {
Expand Down Expand Up @@ -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) }
Expand Down
76 changes: 67 additions & 9 deletions app/src/main/java/com/mapgie/dash/widget/PinnedItemStore.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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<Int>)

/**
* 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(
Expand All @@ -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<PinnedWidgetItem?> = 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<PinnedWidgetItem?> = 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<String>,
idKey: Preferences.Key<String>
): 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<Int> = 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<String>,
idKey: Preferences.Key<String>,
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)
}
}
8 changes: 5 additions & 3 deletions app/src/main/java/com/mapgie/dash/widget/PinnedItemWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,16 +57,17 @@ 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)
}
}
}

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()) {
Expand Down
6 changes: 6 additions & 0 deletions changelog/unreleased/widget-per-instance-pinning.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Loading