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
*
* 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 bool|null $tapRowToComplete Whether clicking anywhere on a checklist row marks it done.
*
* @return DataResponse<Http::STATUS_OK, PantryUserPrefs, array{}>
*
@@ -74,16 +78,16 @@ final class PrefsController extends OCSController {
*/
#[ApiRoute(verb: 'PUT', url: '/api/prefs')]
#[NoAdminRequired]
public function setUserPrefs(?int $lastHouseId = null): DataResponse {
return $this->runAction(function () use ($lastHouseId): DataResponse {
public function setUserPrefs(?int $lastHouseId = null, ?bool $tapRowToComplete = null): DataResponse {
return $this->runAction(function () use ($lastHouseId, $tapRowToComplete): DataResponse {
$uid = $this->requireUid();
$patch = [];
if ($lastHouseId !== null) {
$this->auth->requireMember($lastHouseId, $uid);
$patch['lastHouseId'] = $lastHouseId;
} else {
// Explicit null means clear
$patch['lastHouseId'] = null;
}
if ($tapRowToComplete !== null) {
$patch['tapRowToComplete'] = $tapRowToComplete;
}
$this->prefs->setUserPrefs($uid, $patch);
return new DataResponse($this->prefs->getAllUserPrefs($uid));

View File

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

View File

@@ -14,6 +14,7 @@ use OCP\IL10N;
class PrefsService {
private const KEY_LAST_HOUSE = 'last_house_id';
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 function __construct(
@@ -62,6 +63,25 @@ class PrefsService {
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 -----
/**
@@ -71,6 +91,7 @@ class PrefsService {
return [
'lastHouseId' => $this->getLastHouseId($uid),
'firstDayOfWeek' => $this->getFirstDayOfWeek($uid),
'tapRowToComplete' => $this->getTapRowToComplete($uid),
];
}
@@ -82,6 +103,9 @@ class PrefsService {
$v = $patch['lastHouseId'];
$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 -----

View File

@@ -658,7 +658,8 @@
"type": "object",
"required": [
"lastHouseId",
"firstDayOfWeek"
"firstDayOfWeek",
"tapRowToComplete"
],
"properties": {
"lastHouseId": {
@@ -669,6 +670,9 @@
"firstDayOfWeek": {
"type": "integer",
"format": "int64"
},
"tapRowToComplete": {
"type": "boolean"
}
}
}
@@ -6952,6 +6956,7 @@
"put": {
"operationId": "prefs-set-user-prefs",
"summary": "Update user-level preferences",
"description": "Only the fields present in the request body are updated; omitted fields are left unchanged.",
"tags": [
"prefs"
],
@@ -6976,6 +6981,12 @@
"nullable": true,
"default": null,
"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
/** 0 = Sunday, 1 = Monday, …, 6 = Saturday. Read-only from server. */
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
@@ -15,7 +23,7 @@ export function getUserPrefs(): Promise<UserPrefs> {
userPrefsInflight = ocs
.get<UserPrefs>('/prefs')
.then((resp) => resp.data ?? { lastHouseId: null, firstDayOfWeek: 1 })
.then((resp) => ({ ...userPrefsDefaults, ...resp.data }))
.finally(() => {
userPrefsInflight = null
})
@@ -25,7 +33,7 @@ export function getUserPrefs(): Promise<UserPrefs> {
export async function setUserPrefs(patch: Partial<UserPrefs>): Promise<UserPrefs> {
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)
@@ -38,6 +46,16 @@ export async function setLastHouse(houseId: number | null): Promise<void> {
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 -----
export type PhotoSort = 'custom' | 'newest' | 'oldest' | 'description_asc' | 'description_desc'

View File

@@ -11,6 +11,8 @@ vi.mock('@/api/prefs', () => ({
setImageFolder: vi.fn(),
getNotificationPrefs: vi.fn(),
setNotificationPrefs: vi.fn(),
getTapRowToComplete: vi.fn(),
setTapRowToComplete: vi.fn(),
}))
// Mock Nextcloud Vue components that pull in CSS
@@ -24,7 +26,7 @@ vi.mock('@nextcloud/vue/components/NcAppSettingsDialog', () => ({
vi.mock('@nextcloud/vue/components/NcAppSettingsSection', () => ({
default: {
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'],
},
}))
@@ -66,7 +68,7 @@ const NcAppSettingsDialogStub = {
}
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'],
}
@@ -225,7 +227,8 @@ describe('AccountSettingsDialog', () => {
const wrapper = mountComponent({ open: true, houseId: 1 })
await flushPromises()
const checkboxes = wrapper.findAll('.nc-checkbox')
const notifSection = wrapper.find('#pantry-notifications')
const checkboxes = notifSection.findAll('.nc-checkbox')
expect(checkboxes).toHaveLength(6)
})
@@ -242,7 +245,8 @@ describe('AccountSettingsDialog', () => {
const wrapper = mountComponent({ open: true, houseId: 3 })
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 flushPromises()

View File

@@ -31,6 +31,20 @@
</form>
</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">
<p class="account-settings__hint">{{ strings.notificationsHint }}</p>
<div class="account-settings__checks">
@@ -92,6 +106,7 @@ import {
setNotificationPrefs,
type NotificationPrefs,
} from '@/api/prefs'
import { useTapRowToComplete } from '@/composables/useTapRowToComplete'
const props = defineProps<{ open: boolean; houseId: number | null }>()
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 = {
title: t('pantry', 'Account settings'),
imagesSection: t('pantry', 'Images'),
@@ -211,6 +238,12 @@ const strings = {
notifyItemAdd: t('pantry', 'Checklist items added'),
notifyItemRecur: t('pantry', 'Recurring items reappearing'),
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>
@@ -219,6 +252,10 @@ const strings = {
color: var(--color-text-maxcontrast);
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
&--inline {
margin: 0 0 0 1.85rem;
}
}
.account-settings__form {

View File

@@ -1,15 +1,37 @@
<template>
<li
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"
:draggable="reorderEnabled ? 'true' : 'false'"
@dragstart="onDragStart"
@dragend="onDragEnd"
@dragover.prevent="onDragOver"
>
<NcCheckboxRadioSwitch :model-value="item.done" @update:model-value="$emit('toggle', item.id)">
<span class="checklist-row__label">
<div class="checklist-row__check">
<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
v-if="item.imageFileId"
type="button"
@@ -21,7 +43,7 @@
</button>
<span class="checklist-row__name">{{ item.name }}</span>
</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checklist-row__meta">
<span v-if="item.quantity" class="checklist-row__quantity">&times; {{ item.quantity }}</span>
<span v-if="item.rrule" class="checklist-row__recurrence" :title="recurrenceTooltip">
@@ -94,8 +116,9 @@ const props = withDefaults(
houseId: number
reorderEnabled?: boolean
trashMode?: boolean
tapRowToComplete?: boolean
}>(),
{ reorderEnabled: false, trashMode: false },
{ reorderEnabled: false, trashMode: false, tapRowToComplete: false },
)
const emit = defineEmits<{
@@ -175,7 +198,7 @@ const strings = {
'meta meta';
gap: 0.25rem 0.5rem;
:deep(.checkbox-radio-switch) {
.checklist-row__check {
grid-area: check;
}
@@ -210,19 +233,42 @@ const strings = {
}
}
:deep(.checkbox-content__icon) {
margin-block: auto !important;
&__check {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
:deep(.checkbox-radio-switch__content) {
width: 100%;
max-width: unset;
// When the row-tap pref is on, the label content sits inside the
// NcCheckboxRadioSwitch slot. Stretch the checkbox component (and its
// 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 {
display: inline-flex;
align-items: center;
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 {

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