feat(checklist): add 'click row to complete' user pref

This commit is contained in:
2026-05-15 13:49:47 +03:00
parent 37000ae54f
commit cc6f91d112
10 changed files with 212 additions and 23 deletions

View File

@@ -66,7 +66,11 @@ final class PrefsController extends OCSController {
/** /**
* Update user-level preferences * Update user-level preferences
* *
* Only the fields present in the request body are updated; omitted fields
* are left unchanged.
*
* @param int|null $lastHouseId Last-used house id, or null to clear. * @param int|null $lastHouseId Last-used house id, or null to clear.
* @param bool|null $tapRowToComplete Whether clicking anywhere on a checklist row marks it done.
* *
* @return DataResponse<Http::STATUS_OK, PantryUserPrefs, array{}> * @return DataResponse<Http::STATUS_OK, PantryUserPrefs, array{}>
* *
@@ -74,16 +78,16 @@ final class PrefsController extends OCSController {
*/ */
#[ApiRoute(verb: 'PUT', url: '/api/prefs')] #[ApiRoute(verb: 'PUT', url: '/api/prefs')]
#[NoAdminRequired] #[NoAdminRequired]
public function setUserPrefs(?int $lastHouseId = null): DataResponse { public function setUserPrefs(?int $lastHouseId = null, ?bool $tapRowToComplete = null): DataResponse {
return $this->runAction(function () use ($lastHouseId): DataResponse { return $this->runAction(function () use ($lastHouseId, $tapRowToComplete): DataResponse {
$uid = $this->requireUid(); $uid = $this->requireUid();
$patch = []; $patch = [];
if ($lastHouseId !== null) { if ($lastHouseId !== null) {
$this->auth->requireMember($lastHouseId, $uid); $this->auth->requireMember($lastHouseId, $uid);
$patch['lastHouseId'] = $lastHouseId; $patch['lastHouseId'] = $lastHouseId;
} else { }
// Explicit null means clear if ($tapRowToComplete !== null) {
$patch['lastHouseId'] = null; $patch['tapRowToComplete'] = $tapRowToComplete;
} }
$this->prefs->setUserPrefs($uid, $patch); $this->prefs->setUserPrefs($uid, $patch);
return new DataResponse($this->prefs->getAllUserPrefs($uid)); return new DataResponse($this->prefs->getAllUserPrefs($uid));

View File

@@ -84,6 +84,7 @@ namespace OCA\Pantry;
* @psalm-type PantryUserPrefs = array{ * @psalm-type PantryUserPrefs = array{
* lastHouseId: int|null, * lastHouseId: int|null,
* firstDayOfWeek: int, * firstDayOfWeek: int,
* tapRowToComplete: bool,
* } * }
* *
* @psalm-type PantryHousePrefs = array{ * @psalm-type PantryHousePrefs = array{

View File

@@ -14,6 +14,7 @@ use OCP\IL10N;
class PrefsService { class PrefsService {
private const KEY_LAST_HOUSE = 'last_house_id'; private const KEY_LAST_HOUSE = 'last_house_id';
private const KEY_IMAGE_FOLDER = 'image_folder'; private const KEY_IMAGE_FOLDER = 'image_folder';
private const KEY_TAP_ROW_TO_COMPLETE = 'tap_row_to_complete';
public const DEFAULT_IMAGE_FOLDER = '/Pantry'; public const DEFAULT_IMAGE_FOLDER = '/Pantry';
public function __construct( public function __construct(
@@ -62,6 +63,25 @@ class PrefsService {
return $normalized; return $normalized;
} }
public function getTapRowToComplete(string $uid): bool {
// Off by default — taps only register on the checkbox itself.
return $this->config->getUserValue(
$uid,
Application::APP_ID,
self::KEY_TAP_ROW_TO_COMPLETE,
'0',
) === '1';
}
public function setTapRowToComplete(string $uid, bool $value): void {
$this->config->setUserValue(
$uid,
Application::APP_ID,
self::KEY_TAP_ROW_TO_COMPLETE,
$value ? '1' : '0',
);
}
// ----- Unified user prefs ----- // ----- Unified user prefs -----
/** /**
@@ -71,6 +91,7 @@ class PrefsService {
return [ return [
'lastHouseId' => $this->getLastHouseId($uid), 'lastHouseId' => $this->getLastHouseId($uid),
'firstDayOfWeek' => $this->getFirstDayOfWeek($uid), 'firstDayOfWeek' => $this->getFirstDayOfWeek($uid),
'tapRowToComplete' => $this->getTapRowToComplete($uid),
]; ];
} }
@@ -82,6 +103,9 @@ class PrefsService {
$v = $patch['lastHouseId']; $v = $patch['lastHouseId'];
$this->setLastHouseId($uid, is_int($v) ? $v : null); $this->setLastHouseId($uid, is_int($v) ? $v : null);
} }
if (array_key_exists('tapRowToComplete', $patch) && is_bool($patch['tapRowToComplete'])) {
$this->setTapRowToComplete($uid, $patch['tapRowToComplete']);
}
} }
// ----- Sort preferences ----- // ----- Sort preferences -----

View File

@@ -658,7 +658,8 @@
"type": "object", "type": "object",
"required": [ "required": [
"lastHouseId", "lastHouseId",
"firstDayOfWeek" "firstDayOfWeek",
"tapRowToComplete"
], ],
"properties": { "properties": {
"lastHouseId": { "lastHouseId": {
@@ -669,6 +670,9 @@
"firstDayOfWeek": { "firstDayOfWeek": {
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
},
"tapRowToComplete": {
"type": "boolean"
} }
} }
} }
@@ -6952,6 +6956,7 @@
"put": { "put": {
"operationId": "prefs-set-user-prefs", "operationId": "prefs-set-user-prefs",
"summary": "Update user-level preferences", "summary": "Update user-level preferences",
"description": "Only the fields present in the request body are updated; omitted fields are left unchanged.",
"tags": [ "tags": [
"prefs" "prefs"
], ],
@@ -6976,6 +6981,12 @@
"nullable": true, "nullable": true,
"default": null, "default": null,
"description": "Last-used house id, or null to clear." "description": "Last-used house id, or null to clear."
},
"tapRowToComplete": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether clicking anywhere on a checklist row marks it done."
} }
} }
} }

View File

@@ -6,6 +6,14 @@ export interface UserPrefs {
lastHouseId: number | null lastHouseId: number | null
/** 0 = Sunday, 1 = Monday, …, 6 = Saturday. Read-only from server. */ /** 0 = Sunday, 1 = Monday, …, 6 = Saturday. Read-only from server. */
firstDayOfWeek: number firstDayOfWeek: number
/** When true, clicking anywhere on a checklist row marks the item done. */
tapRowToComplete: boolean
}
const userPrefsDefaults: UserPrefs = {
lastHouseId: null,
firstDayOfWeek: 1,
tapRowToComplete: false,
} }
let userPrefsInflight: Promise<UserPrefs> | null = null let userPrefsInflight: Promise<UserPrefs> | null = null
@@ -15,7 +23,7 @@ export function getUserPrefs(): Promise<UserPrefs> {
userPrefsInflight = ocs userPrefsInflight = ocs
.get<UserPrefs>('/prefs') .get<UserPrefs>('/prefs')
.then((resp) => resp.data ?? { lastHouseId: null, firstDayOfWeek: 1 }) .then((resp) => ({ ...userPrefsDefaults, ...resp.data }))
.finally(() => { .finally(() => {
userPrefsInflight = null userPrefsInflight = null
}) })
@@ -25,7 +33,7 @@ export function getUserPrefs(): Promise<UserPrefs> {
export async function setUserPrefs(patch: Partial<UserPrefs>): Promise<UserPrefs> { export async function setUserPrefs(patch: Partial<UserPrefs>): Promise<UserPrefs> {
const resp = await ocs.put<UserPrefs>('/prefs', patch) const resp = await ocs.put<UserPrefs>('/prefs', patch)
return resp.data ?? { lastHouseId: null, firstDayOfWeek: 1 } return { ...userPrefsDefaults, ...resp.data }
} }
// Convenience wrappers (used widely, keep the simple API) // Convenience wrappers (used widely, keep the simple API)
@@ -38,6 +46,16 @@ export async function setLastHouse(houseId: number | null): Promise<void> {
await setUserPrefs({ lastHouseId: houseId }) await setUserPrefs({ lastHouseId: houseId })
} }
export async function getTapRowToComplete(): Promise<boolean> {
const prefs = await getUserPrefs()
return prefs.tapRowToComplete
}
export async function setTapRowToComplete(value: boolean): Promise<boolean> {
const prefs = await setUserPrefs({ tapRowToComplete: value })
return prefs.tapRowToComplete
}
// ----- Per-house prefs ----- // ----- Per-house prefs -----
export type PhotoSort = 'custom' | 'newest' | 'oldest' | 'description_asc' | 'description_desc' export type PhotoSort = 'custom' | 'newest' | 'oldest' | 'description_asc' | 'description_desc'

View File

@@ -11,6 +11,8 @@ vi.mock('@/api/prefs', () => ({
setImageFolder: vi.fn(), setImageFolder: vi.fn(),
getNotificationPrefs: vi.fn(), getNotificationPrefs: vi.fn(),
setNotificationPrefs: vi.fn(), setNotificationPrefs: vi.fn(),
getTapRowToComplete: vi.fn(),
setTapRowToComplete: vi.fn(),
})) }))
// Mock Nextcloud Vue components that pull in CSS // Mock Nextcloud Vue components that pull in CSS
@@ -24,7 +26,7 @@ vi.mock('@nextcloud/vue/components/NcAppSettingsDialog', () => ({
vi.mock('@nextcloud/vue/components/NcAppSettingsSection', () => ({ vi.mock('@nextcloud/vue/components/NcAppSettingsSection', () => ({
default: { default: {
name: 'NcAppSettingsSection', name: 'NcAppSettingsSection',
template: '<div class="nc-app-settings-section"><slot /></div>', template: '<div class="nc-app-settings-section" :id="id"><slot /></div>',
props: ['id', 'name'], props: ['id', 'name'],
}, },
})) }))
@@ -66,7 +68,7 @@ const NcAppSettingsDialogStub = {
} }
const NcAppSettingsSectionStub = { const NcAppSettingsSectionStub = {
template: '<div class="nc-app-settings-section"><slot /></div>', template: '<div class="nc-app-settings-section" :id="id"><slot /></div>',
props: ['id', 'name'], props: ['id', 'name'],
} }
@@ -225,7 +227,8 @@ describe('AccountSettingsDialog', () => {
const wrapper = mountComponent({ open: true, houseId: 1 }) const wrapper = mountComponent({ open: true, houseId: 1 })
await flushPromises() await flushPromises()
const checkboxes = wrapper.findAll('.nc-checkbox') const notifSection = wrapper.find('#pantry-notifications')
const checkboxes = notifSection.findAll('.nc-checkbox')
expect(checkboxes).toHaveLength(6) expect(checkboxes).toHaveLength(6)
}) })
@@ -242,7 +245,8 @@ describe('AccountSettingsDialog', () => {
const wrapper = mountComponent({ open: true, houseId: 3 }) const wrapper = mountComponent({ open: true, houseId: 3 })
await flushPromises() await flushPromises()
const checkbox = wrapper.find('.nc-checkbox input') const notifSection = wrapper.find('#pantry-notifications')
const checkbox = notifSection.find('.nc-checkbox input')
await checkbox.setValue(false) await checkbox.setValue(false)
await flushPromises() await flushPromises()

View File

@@ -31,6 +31,20 @@
</form> </form>
</NcAppSettingsSection> </NcAppSettingsSection>
<NcAppSettingsSection id="pantry-interface" :name="strings.interfaceSection">
<div class="account-settings__checks">
<NcCheckboxRadioSwitch
:model-value="tapRowToComplete"
@update:model-value="updateTapRowToComplete($event)"
>
{{ strings.tapRowToCompleteLabel }}
</NcCheckboxRadioSwitch>
<p class="account-settings__hint account-settings__hint--inline">
{{ strings.tapRowToCompleteHint }}
</p>
</div>
</NcAppSettingsSection>
<NcAppSettingsSection id="pantry-notifications" :name="strings.notificationsSection"> <NcAppSettingsSection id="pantry-notifications" :name="strings.notificationsSection">
<p class="account-settings__hint">{{ strings.notificationsHint }}</p> <p class="account-settings__hint">{{ strings.notificationsHint }}</p>
<div class="account-settings__checks"> <div class="account-settings__checks">
@@ -92,6 +106,7 @@ import {
setNotificationPrefs, setNotificationPrefs,
type NotificationPrefs, type NotificationPrefs,
} from '@/api/prefs' } from '@/api/prefs'
import { useTapRowToComplete } from '@/composables/useTapRowToComplete'
const props = defineProps<{ open: boolean; houseId: number | null }>() const props = defineProps<{ open: boolean; houseId: number | null }>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>() const emit = defineEmits<{ 'update:open': [value: boolean] }>()
@@ -187,6 +202,18 @@ async function updateNotifPref(key: keyof NotificationPrefs, value: boolean) {
} }
} }
// ----- Interface prefs -----
const { tapRowToComplete, set: setTapRowPref } = useTapRowToComplete()
async function updateTapRowToComplete(value: boolean) {
try {
await setTapRowPref(value)
} catch {
// Composable already reverted the optimistic update.
}
}
const strings = { const strings = {
title: t('pantry', 'Account settings'), title: t('pantry', 'Account settings'),
imagesSection: t('pantry', 'Images'), imagesSection: t('pantry', 'Images'),
@@ -211,6 +238,12 @@ const strings = {
notifyItemAdd: t('pantry', 'Checklist items added'), notifyItemAdd: t('pantry', 'Checklist items added'),
notifyItemRecur: t('pantry', 'Recurring items reappearing'), notifyItemRecur: t('pantry', 'Recurring items reappearing'),
notifyItemDone: t('pantry', 'Checklist items completed'), notifyItemDone: t('pantry', 'Checklist items completed'),
interfaceSection: t('pantry', 'Interface'),
tapRowToCompleteLabel: t('pantry', 'Click row to complete items'),
tapRowToCompleteHint: t(
'pantry',
'When off, items are only marked complete by clicking the checkbox.',
),
} }
</script> </script>
@@ -219,6 +252,10 @@ const strings = {
color: var(--color-text-maxcontrast); color: var(--color-text-maxcontrast);
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
font-size: 0.9rem; font-size: 0.9rem;
&--inline {
margin: 0 0 0 1.85rem;
}
} }
.account-settings__form { .account-settings__form {

View File

@@ -1,15 +1,37 @@
<template> <template>
<li <li
class="checklist-row" class="checklist-row"
:class="{ 'checklist-row--done': item.done, 'checklist-row--dragging': isDragging }" :class="{
'checklist-row--done': item.done,
'checklist-row--dragging': isDragging,
}"
:data-drag-id="item.id" :data-drag-id="item.id"
:draggable="reorderEnabled ? 'true' : 'false'" :draggable="reorderEnabled ? 'true' : 'false'"
@dragstart="onDragStart" @dragstart="onDragStart"
@dragend="onDragEnd" @dragend="onDragEnd"
@dragover.prevent="onDragOver" @dragover.prevent="onDragOver"
> >
<NcCheckboxRadioSwitch :model-value="item.done" @update:model-value="$emit('toggle', item.id)"> <div class="checklist-row__check">
<span class="checklist-row__label"> <NcCheckboxRadioSwitch
:model-value="item.done"
:aria-label="tapRowToComplete ? undefined : item.name"
:class="{ 'checklist-row__check-fill': tapRowToComplete }"
@update:model-value="$emit('toggle', item.id)"
>
<span v-if="tapRowToComplete" class="checklist-row__label">
<button
v-if="item.imageFileId"
type="button"
class="checklist-row__thumb"
:aria-label="strings.viewImage"
@click.stop.prevent="$emit('preview', item)"
>
<img :src="thumbUrl" :alt="item.name" />
</button>
<span class="checklist-row__name">{{ item.name }}</span>
</span>
</NcCheckboxRadioSwitch>
<span v-if="!tapRowToComplete" class="checklist-row__label checklist-row__label--standalone">
<button <button
v-if="item.imageFileId" v-if="item.imageFileId"
type="button" type="button"
@@ -21,7 +43,7 @@
</button> </button>
<span class="checklist-row__name">{{ item.name }}</span> <span class="checklist-row__name">{{ item.name }}</span>
</span> </span>
</NcCheckboxRadioSwitch> </div>
<div class="checklist-row__meta"> <div class="checklist-row__meta">
<span v-if="item.quantity" class="checklist-row__quantity">&times; {{ item.quantity }}</span> <span v-if="item.quantity" class="checklist-row__quantity">&times; {{ item.quantity }}</span>
<span v-if="item.rrule" class="checklist-row__recurrence" :title="recurrenceTooltip"> <span v-if="item.rrule" class="checklist-row__recurrence" :title="recurrenceTooltip">
@@ -94,8 +116,9 @@ const props = withDefaults(
houseId: number houseId: number
reorderEnabled?: boolean reorderEnabled?: boolean
trashMode?: boolean trashMode?: boolean
tapRowToComplete?: boolean
}>(), }>(),
{ reorderEnabled: false, trashMode: false }, { reorderEnabled: false, trashMode: false, tapRowToComplete: false },
) )
const emit = defineEmits<{ const emit = defineEmits<{
@@ -175,7 +198,7 @@ const strings = {
'meta meta'; 'meta meta';
gap: 0.25rem 0.5rem; gap: 0.25rem 0.5rem;
:deep(.checkbox-radio-switch) { .checklist-row__check {
grid-area: check; grid-area: check;
} }
@@ -210,19 +233,42 @@ const strings = {
} }
} }
:deep(.checkbox-content__icon) { &__check {
margin-block: auto !important; display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
} }
:deep(.checkbox-radio-switch__content) { // When the row-tap pref is on, the label content sits inside the
width: 100%; // NcCheckboxRadioSwitch slot. Stretch the checkbox component (and its
max-width: unset; // inner content wrapper) so the hover highlight and click target span
// the whole row.
&__check-fill {
flex: 1;
min-width: 0;
:deep(.checkbox-radio-switch__content) {
width: 100%;
max-width: unset;
}
}
:deep(.checkbox-content__icon) {
margin-block: auto !important;
} }
&__label { &__label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
min-width: 0;
// Standalone label (checkbox-only mode): fills the remaining space
// next to the checkbox in the flex container.
&--standalone {
flex: 1;
}
} }
&__thumb { &__thumb {

View File

@@ -0,0 +1,40 @@
import { ref } from 'vue'
import { getTapRowToComplete, setTapRowToComplete } from '@/api/prefs'
// Module-level ref so every consumer reads the same reactive value.
// Updating it from the settings dialog instantly reflects in any open
// checklist view, no remount needed.
const value = ref(false)
let loaded = false
let inflight: Promise<void> | null = null
async function load(): Promise<void> {
if (loaded) return
if (inflight) return inflight
inflight = (async () => {
try {
value.value = await getTapRowToComplete()
loaded = true
} finally {
inflight = null
}
})()
return inflight
}
async function set(next: boolean): Promise<void> {
const previous = value.value
value.value = next
try {
value.value = await setTapRowToComplete(next)
loaded = true
} catch (e) {
value.value = previous
throw e
}
}
export function useTapRowToComplete() {
void load()
return { tapRowToComplete: value, set }
}

View File

@@ -102,6 +102,7 @@
:house-id="houseIdNum" :house-id="houseIdNum"
:reorder-enabled="isCustomSort" :reorder-enabled="isCustomSort"
:trash-mode="trashMode" :trash-mode="trashMode"
:tap-row-to-complete="tapRowToComplete"
@toggle="handleToggle" @toggle="handleToggle"
@view="openView" @view="openView"
@edit="startEdit" @edit="startEdit"
@@ -261,6 +262,7 @@ import type { ItemInput } from '@/api/lists'
import type { Checklist, ChecklistItem } from '@/api/types' import type { Checklist, ChecklistItem } from '@/api/types'
import type { ChecklistItemSort } from '@/api/prefs' import type { ChecklistItemSort } from '@/api/prefs'
import { getChecklistItemSort, setChecklistItemSort } from '@/api/prefs' import { getChecklistItemSort, setChecklistItemSort } from '@/api/prefs'
import { useTapRowToComplete } from '@/composables/useTapRowToComplete'
const props = defineProps<{ houseId: string; listId: string }>() const props = defineProps<{ houseId: string; listId: string }>()
@@ -335,6 +337,8 @@ async function loadList() {
list.value = await getList(houseIdNum.value, listIdNum.value) list.value = await getList(houseIdNum.value, listIdNum.value)
} }
const { tapRowToComplete } = useTapRowToComplete()
onMounted(async () => { onMounted(async () => {
await loadSortPref() await loadSortPref()
await Promise.all([loadList(), load(), categories.load()]) await Promise.all([loadList(), load(), categories.load()])