Kawai-Focus 2.8: путь к MVP1 — конструктор таймеров

от автора

Обложка

Обложка

Вступление

Всем доброго дня! В предыдущей статье Кавай-Фокус 2.7: путь к MVP1 — цепочка таймеров и воспроизведение звука:

  1. Добавлено воспроизведение звука таймера через Web Audio API;

  2. Написана механика воспроизведения цепочки из таймеров для Pomodoro.

После того как я закончил экран «Таймер», который воспроизводит образцы сессий, наступило время дать пользователю возможность добавления своих таймеров, которые будут ему удобны. Для этих целей я напишу экран-конструктор, в котором пользователь будет создавать свой таймер.

Все новые таймеры будут сохраняться в базу данных SQLite3, поэтому нужно будет написать CRUD-операцию для создания таймера. Также я добавлю операции для удаления и редактирования записей. К новому функционалу я подключу кнопки редактирования и удаления. Это позволит пользователю полноценно управлять таймерами.

Заваривайте чай, доставайте вкусняшки — пора «высаживать грядки из помидоров»! 🍅


Crud операции

Первое, что я сделаю для расширения функционала — напишу CRUD-операции и строки DML-запросов к базе данных. Мне необходимо: создавать, редактировать и удалять таймеры.

timerDML.ts

Расширю данный файл новыми строками кода, которые являются основой для запросов к БД.

export const INSERT_TIMER = 'INSERT INTO timer (title, pomodoro_time, break_time, break_long_time, count_pomodoro) VALUES (?, ?, ?, ?, ?)'

INSERT_TIMER — SQL-запрос для создания нового таймера:

  • вставляет запись в таблицу timer,

  • принимает параметры: title, pomodoro_time, break_time, break_long_time, count_pomodoro,

  • использует плейсхолдеры ? для безопасной подстановки значений.

export const UPDATE_TIMER = 'UPDATE timer SET title = ?, pomodoro_time = ?, break_time = ?, break_long_time = ?, count_pomodoro = ? WHERE id = ?'

UPDATE_TIMER — SQL-запрос для обновления существующего таймера:

  • обновляет все основные поля таймера,

  • выбирает запись по id,

  • также использует параметризованный запрос, чтобы избежать SQL-инъекций.

export const DELETE_TIMER = 'DELETE FROM timer WHERE id = ?'

DELETE_TIMER — SQL-запрос для удаления таймера:

  • удаляет запись из таблицы timer по id,

  • самый простой запрос из трёх, выполняет полное удаление сущности.

Далее эти строки будут использованы в CRUD-функциях.

timerCrud.ts

Теперь напишу сами функции для CRUD-операций. Не буду показывать импорт timerDML.ts, так как и так очевидно, что он уже добавлен.

/** Создаёт новый таймер */export async function createTimer(  title: string,   pomodoroTime: number,   breakTime: number,   breakLongTime: number,   countPomodoro: number): Promise<QueryResult> {  const db = await getDb();  return await db.execute(INSERT_TIMER, [    title,     pomodoroTime,     breakTime,     breakLongTime,     countPomodoro  ]);}

createTimer — создание таймера:

  • получает параметры таймера из UI,

  • открывает соединение с БД через getDb(),

  • выполняет INSERT_TIMER,

  • возвращает QueryResult (включая lastInsertId).

/** Обновляет таймер */export async function updateTimer(  TimerId: number,  title: string,  pomodoroTime: number,  breakTime: number,  breakLongTime: number,  countPomodoro: number): Promise<QueryResult> {  const db = await getDb();  return await db.execute(UPDATE_TIMER, [    title,    pomodoroTime,    breakTime,    breakLongTime,    countPomodoro,    TimerId  ]);}

updateTimer — обновление таймера:

  • принимает TimerId и новые значения,

  • выполняет UPDATE_TIMER,

  • обновляет запись по id, не создавая новую сущность.

/** Удаляет таймер */export async function deleteTimer(  TimerId: number): Promise<QueryResult> {  const db = await getDb();  return await db.execute(DELETE_TIMER, [TimerId]);}

deleteTimer — удаление таймера:

  • принимает только TimerId,

  • выполняет DELETE_TIMER,

  • полностью удаляет запись из базы.

Новые CRUD-функции для таймера готовы, можно приступать к написанию view для создания и редактирования таймера.


TimerUpdate

Мне нужен view TimerUpdate, который будет выполнять роль формы для создания и редактирования таймера. Я использую один view и для создания, и для редактирования, так как это экономит строки кода и избавляет от необходимости писать два отдельных компонента.

Я не буду подробно объяснять и показывать рутинные вещи, такие как стили CSS или подключение роутов, и сосредоточусь только на основной логике экрана и его HTML.

TimerUpdate.ts

Это основная логика view, которая обрабатывает команды, определяет, нужно ли создавать или обновлять данные формы и т.д.

import { computed, defineComponent, onMounted, ref, watch } from 'vue';import { IonIcon, IonPage, IonContent } from '@ionic/vue';import { useRoute, useRouter } from 'vue-router';import { arrowBackOutline, checkmarkOutline } from 'ionicons/icons';import { createTimer, getTimer, updateTimer } from '@/db/crud/timerCrud';import type { TimerRow } from '@/types/timerType';const LIMITS = {  pomodoroTime: { min: 10, max: 90 },  breakTime: { min: 3, max: 10 },  breakLongTime: { min: 15, max: 40 },  countPomodoro: { min: 2, max: 8 },} as const;export default defineComponent({  name: 'TimerUpdate',  components: { IonIcon, IonPage, IonContent },  setup() {    const route = useRoute();    const router = useRouter();    const isCreateMode = computed(() => route.params.id = 'new');    const currentId = computed(() => (isCreateMode.value ? 0 : Number(route.params.id)));    const loading = ref(false);    const error = ref<string | null>(null);    const titleError = ref('');    const modeLabel = computed(() => (isCreateMode.value ? 'Создать таймер' : 'Редактировать таймер'));    const form = ref<TimerRow>({      id: currentId.value,      title: '',      pomodoro_time: LIMITS.pomodoroTime.min,      break_time: LIMITS.breakTime.min,      break_long_time: LIMITS.breakLongTime.min,      count_pomodoro: LIMITS.countPomodoro.min,    });    /**     * Безопасно преобразует значение в число.     * Если преобразование невозможно, возвращает значение по умолчанию.     */    const safeNumber = (value: unknown, fallback: number): number => {      const parsed = Number(value);      return Number.isFinite(parsed) ? parsed : fallback;    };    /**     * Ограничивает число диапазоном min-max.     */    const clamp = (value: number, min: number, max: number): number => {      return Math.min(max, Math.max(min, value));    };    /**     * Нормализует объект таймера и приводит числовые поля     * к допустимым диапазонам значений.     */    const normalizeTimer = (timer?: Partial<TimerRow>): TimerRow => {      return {        id: currentId.value,        title: timer?.title ?? '',        pomodoro_time: clamp(safeNumber(timer?.pomodoro_time, LIMITS.pomodoroTime.min), LIMITS.pomodoroTime.min, LIMITS.pomodoroTime.max),        break_time: clamp(safeNumber(timer?.break_time, LIMITS.breakTime.min), LIMITS.breakTime.min, LIMITS.breakTime.max),        break_long_time: clamp(safeNumber(timer?.break_long_time, LIMITS.breakLongTime.min), LIMITS.breakLongTime.min, LIMITS.breakLongTime.max),        count_pomodoro: clamp(safeNumber(timer?.count_pomodoro, LIMITS.countPomodoro.min), LIMITS.countPomodoro.min, LIMITS.countPomodoro.max),      };    };    /**     * Ограничивает значение поля формы указанным диапазоном.     */    const clampField = (field: keyof TimerRow, min: number, max: number): void => {      if (typeof form.value[field] = 'number') {        form.value[field] = clamp(safeNumber(form.value[field], min), min, max) as never;      }    };    /**     * Обрабатывает изменение значения слайдера.     */    const onSliderInput = (field: keyof TimerRow, min: number, max: number, value: Event): void => {      const nextValue = safeNumber((value.target as HTMLInputElement | null)?.value, min);      if (typeof form.value[field] = 'number') {        form.value[field] = clamp(nextValue, min, max) as never;      }    };    /**     * Загружает таймер из базы данных либо     * создаёт форму со значениями по умолчанию.     */    const loadTimer = async (): Promise<void> => {      if (isCreateMode.value) {        form.value = normalizeTimer({          id: 0,          title: '',          pomodoro_time: LIMITS.pomodoroTime.min,          break_time: LIMITS.breakTime.min,          break_long_time: LIMITS.breakLongTime.min,          count_pomodoro: LIMITS.countPomodoro.min,        });        return;      }      loading.value = true;      error.value = null;      try {        const timer = await getTimer(currentId.value);        form.value = normalizeTimer(timer);      } catch (e) {        error.value = e instanceof Error ? e.message : 'Ошибка загрузки таймера';      } finally {        loading.value = false;      }    };    /**     * Проверяет корректность названия таймера.     */    const validateTitle = (): boolean => {      const trimmedTitle = form.value.title.trim();      if (!trimmedTitle) {        titleError.value = 'Введите название таймера';        return false;      }      titleError.value = '';      return true;    };    /**     * Создаёт новый таймер или обновляет существующий.     */    const saveTimer = async (): Promise<void> => {      if (!validateTitle()) {        return;      }      try {        const trimmedTitle = form.value.title.trim();        form.value = normalizeTimer({          id: isCreateMode.value ? 0 : currentId.value,          title: trimmedTitle,          pomodoro_time: form.value.pomodoro_time,          break_time: form.value.break_time,          break_long_time: form.value.break_long_time,          count_pomodoro: form.value.count_pomodoro,        });        if (isCreateMode.value) {          const result = await createTimer(            form.value.title,            form.value.pomodoro_time,            form.value.break_time,            form.value.break_long_time,            form.value.count_pomodoro,          );          const newId = Number((result as { lastInsertId?: number }).lastInsertId ?? 0);          if (!newId) {            throw new Error('Не удалось получить id созданного таймера');          }          router.push(`/timer/${newId}`);          return;        }        await updateTimer(          currentId.value,          form.value.title,          form.value.pomodoro_time,          form.value.break_time,          form.value.break_long_time,          form.value.count_pomodoro,        );        router.push(`/timer/${currentId.value}`);      } catch (e) {        error.value = e instanceof Error ? e.message : 'Ошибка сохранения таймера';      }    };    /**     * Возвращает пользователя на предыдущую страницу.     */    const goBack = (): void => {      router.back();    };    onMounted(loadTimer);    watch(() => route.params.id, () => {      loadTimer();    });    watch(() => form.value.title, () => {      if (titleError.value && form.value.title.trim()) {        titleError.value = '';      }    });    return {      form,      loading,      error,      titleError,      isCreateMode,      modeLabel,      LIMITS,      arrowBackOutline,      checkmarkOutline,      clampField,      onSliderInput,      saveTimer,      validateTitle,      goBack,    };  },});

Разберём по шагам:

  1. Определение режима работы — через isCreateMode компонент понимает, создаём мы новый таймер или редактируем существующий (по route.params.id).

  2. Инициализация формыform хранит состояние таймера и сразу заполняется значениями по умолчанию из LIMITS.

  3. Защита данныхsafeNumber и clamp обеспечивают:

    • корректное преобразование типов,

    • ограничение значений в допустимых пределах.

  4. Нормализация таймераnormalizeTimer приводит данные из базы к валидному виду и гарантирует, что UI никогда не получит некорректные значения.

  5. Загрузка данныхloadTimer:

    • в режиме создания просто задаёт дефолты,

    • в режиме редактирования запрашивает таймер из БД через getTimer.

  6. Валидация формыvalidateTitle проверяет, что поле названия не пустое, и показывает ошибку, если это не так.

  7. Изменение через слайдерыonSliderInput и clampField синхронизируют UI и форму, не позволяя выйти за границы LIMITS.

  8. Сохранение данныхsaveTimer:

    • валидирует форму,

    • нормализует данные,

    • либо создаёт новый таймер (createTimer),

    • либо обновляет существующий (updateTimer),

    • после чего перенаправляет пользователя на экран таймера.

  9. Навигация назадgoBack просто возвращает пользователя на предыдущий экран через router.back().

  10. Реактивные обновления:

    • watch(route.params.id) перезагружает таймер при смене маршрута,

    • watch(form.title) очищает ошибку, если пользователь начал исправлять поле.

  11. Инициализация компонентаonMounted(loadTimer) загружает данные сразу при открытии страницы.

TimerUpdate.html

Теперь напишу HTML для этого view, чтобы можно было отрисовать интерфейс редактирования таймера.

<ion-page>  <ion-content>    <div class="timer-update-container">      <div class="page-header">        <button class="back-button" type="button" @click="goBack">          <ion-icon :icon="arrowBackOutline"></ion-icon>          Назад        </button>        <h1>{{ modeLabel }}</h1>        <p>Подстрой параметры под нужный режим работы.</p>      </div>      <div class="form-shell">        <div v-if="loading" class="state-card">Загрузка таймера...</div>        <div v-else-if="error" class="state-card error">{{ error }}</div>        <form v-else class="timer-form" @submit.prevent="saveTimer">          <label class="field-card">            <span class="field-label">Название</span>            <input              class="title-input"              :class="{ 'title-input--error': titleError }"              v-model="form.title"              type="text"              maxlength="100"              placeholder="Например, Утренний фокус"            />            <p v-if="titleError" class="field-error">{{ titleError }}</p>          </label>          <section class="field-card tomato-card">            <div class="field-head">              <span class="field-label">Количество помидоров</span>            </div>            <div class="tomato-stage">              <div class="tomato-display">                <span class="tomato-emoji">🍅</span>                <strong>{{ form.count_pomodoro }}</strong>              </div>              <input                v-model.number="form.count_pomodoro"                type="range"                :min="LIMITS.countPomodoro.min"                :max="LIMITS.countPomodoro.max"                @input="onSliderInput('count_pomodoro', LIMITS.countPomodoro.min, LIMITS.countPomodoro.max, $event)"              />            </div>          </section>          <section class="field-card slider-card">            <div class="field-head">              <span class="field-label">Длительность помидора</span>            </div>            <div class="slider-wrap">              <div class="value-pill">                {{ form.pomodoro_time }}<small>мин</small>              </div>              <input                v-model.number="form.pomodoro_time"                type="range"                :min="LIMITS.pomodoroTime.min"                :max="LIMITS.pomodoroTime.max"                @input="onSliderInput('pomodoro_time', LIMITS.pomodoroTime.min, LIMITS.pomodoroTime.max, $event)"              />            </div>            <p class="range-note">              Пределы: {{ LIMITS.pomodoroTime.min }} — {{ LIMITS.pomodoroTime.max }} минут            </p>          </section>          <section class="field-card slider-card">            <div class="field-head">              <span class="field-label">Короткий перерыв</span>            </div>            <div class="slider-wrap">              <div class="value-pill">                {{ form.break_time }}<small>мин</small>              </div>              <input                v-model.number="form.break_time"                type="range"                :min="LIMITS.breakTime.min"                :max="LIMITS.breakTime.max"                @input="onSliderInput('break_time', LIMITS.breakTime.min, LIMITS.breakTime.max, $event)"              />            </div>            <p class="range-note">              Пределы: {{ LIMITS.breakTime.min }} — {{ LIMITS.breakTime.max }} минут            </p>          </section>          <section class="field-card slider-card">            <div class="field-head">              <span class="field-label">Длинный перерыв</span>            </div>            <div class="slider-wrap">              <div class="value-pill">                {{ form.break_long_time }}<small>мин</small>              </div>              <input                v-model.number="form.break_long_time"                type="range"                :min="LIMITS.breakLongTime.min"                :max="LIMITS.breakLongTime.max"                @input="onSliderInput('break_long_time', LIMITS.breakLongTime.min, LIMITS.breakLongTime.max, $event)"              />            </div>            <p class="range-note">              Пределы: {{ LIMITS.breakLongTime.min }} — {{ LIMITS.breakLongTime.max }} минут            </p>          </section>          <button class="save-button" type="submit">            <ion-icon :icon="checkmarkOutline"></ion-icon>            Сохранить и открыть          </button>        </form>      </div>    </div>  </ion-content></ion-page>

Разберём по шагам:

  1. Каркас страницы<ion-page> и <ion-content> задают базовую структуру экрана Ionic-приложения, внутри которой рендерится весь UI таймера.

  2. Шапка экрана — блок page-header содержит:

    • кнопку «Назад», вызывающую goBack,

    • заголовок режима (modeLabel),

    • описание экрана для пользователя.

  3. Состояния интерфейса — через v-if / v-else-if / v-else реализована логика:

    • loading → показывается загрузка,

    • error → отображается ошибка,

    • иначе → отображается форма.

  4. Поле названия таймера — двусторонняя привязка v-model="form.title":

    • хранит название таймера,

    • отображает ошибку через titleError,

    • ограничено maxlength = 100.

  5. Валидация названия — ошибка выводится под полем, а CSS-класс title-input--error визуально подсвечивает некорректный ввод.

  6. Слайдер количества помидоров — управляет form.count_pomodoro:

    • отображает текущее значение рядом с 🍅,

    • ограничен диапазоном из LIMITS,

    • изменения идут через onSliderInput.

  7. Слайдер длительности помидора — управляет pomodoro_time:

    • текущее значение показано в value-pill,

    • ниже отображаются границы допустимых значений.

  8. Слайдер короткого перерыва — аналогичная логика для break_time:

    • синхронизация с формой через v-model.number,

    • контроль диапазона через LIMITS.

  9. Слайдер длинного перерыва — управляет break_long_time:

    • полностью повторяет структуру предыдущих слайдеров,

    • отличается только диапазоном значений.

  10. Общий паттерн слайдеров — все параметры таймера построены по единой схеме:

    • значение → v-model,

    • ограничения → LIMITS,

    • обработка → onSliderInput.

  11. Кнопка сохранения — триггерит saveTimer:

    • предотвращает стандартный submit,

    • запускает создание или обновление таймера,

    • переводит пользователя на экран таймера.

  12. UX-фокус экрана — интерфейс построен так, чтобы пользователь:

    • сразу видел текущее состояние,

    • мог менять параметры через слайдеры без ручного ввода,

    • получал мгновенную обратную связь по значениям.

Я рад, что закончил с самой сложной логикой и перейду к расширению и внедрению новой логики в остальные views.


Timer

В данном view мне нужно сделать небольшое обновление кода. Дело в том, что в прошлый раз я не добавил в таймер кнопку «Назад». Это приводит к тому, что из таймера невозможно выйти иначе, кроме как закрыть окно приложения, нажав на крестик.

Timer.ts

В качестве новой логики мне нужно добавить кнопку «Назад» и написать логику для её отображения. Я не хочу показывать её во время воспроизведения таймера.

Также я хочу добавить модальное окно, которое будет уточнять у пользователя, действительно ли он хочет прервать таймер и выйти. Я решил показывать его только если таймер стоит на паузе. Если пользователь нажал кнопку «Стоп», я не буду его дополнительно спрашивать при нажатии на «Назад».

// Другой код/** Определяет, нужно ли показывать кнопку "Назад". */const showBackButton = computed(() => {  return (    state.value = 'paused' ||    (state.value = 'idle' &&      countdown.value?.isRunning.value = false &&      countdown.value?.timerNow.value ! undefined)  );});/** Обрабатывает попытку выхода из таймера. */const askLeaveTimer = (): void => {  if (state.value = 'paused') {    confirmLeave.value = true;    return;  }  router.back();};/** Закрывает модальное окно подтверждения выхода. */const closeLeaveModal = (): void => {  confirmLeave.value = false;};/** Подтверждает выход из таймера. */const confirmLeaveTimer = (): void => {  confirmLeave.value = false;  countdown.value?.stop();  state.value = 'idle';  router.back();};// Другой код

Разберём по шагам:

1. Логика отображения кнопки «Назад»
showBackButton вычисляет, можно ли показывать кнопку выхода:

  • если таймер на паузе → показываем,

  • если таймер в idle, но уже запускался раньше → тоже показываем,

  • иначе кнопка скрыта. 2. Попытка выхода из таймера askLeaveTimer обрабатывает клик по «Назад»:

  • если таймер стоит на паузе → не выходим сразу,

    • открываем модальное окно подтверждения,

  • если таймер не активен → сразу возвращаемся назад. 3. Закрытие модального окна closeLeaveModal:

  • просто сбрасывает флаг confirmLeave,

  • пользователь остаётся на текущем экране. 4. Подтверждение выхода confirmLeaveTimer выполняет полный выход:

  • закрывает модальное окно,

  • останавливает таймер (stop()),

  • сбрасывает состояние на idle,

  • возвращает пользователя на предыдущий экран.

Timer.html

Теперь я напишу .html, который будет отображать новую информацию на экране.

<div class="page-header">  <button    v-if="showBackButton"    class="btn-back"    type="button"    @click="askLeaveTimer"  >    <ion-icon :icon="arrowBackOutline"></ion-icon>    Назад  </button>  <h1>Таймер</h1></div><div  v-if="confirmLeave"  class="confirm-overlay"  @click.self="closeLeaveModal">  <div class="confirm-card">    <h3>Выйти из таймера?</h3>    <p>      Текущий прогресс будет сброшен, если вы уйдёте из этого экрана.    </p>    <div class="confirm-actions">      <button        class="btn-timer btn-timer-pause"        type="button"        @click="closeLeaveModal"      >        Нет      </button>      <button        class="btn-timer btn-timer-stop"        type="button"        @click="confirmLeaveTimer"      >        Да      </button>    </div>  </div></div>

Разберём по шагам:

1. Шапка экрана таймера
Блок page-header формирует верхнюю часть интерфейса:

  • содержит кнопку «Назад» (если разрешено логикой showBackButton)

  • отображает заголовок страницы «Таймер» 2. Условная кнопка возврата Кнопка назад управляется через v-if="showBackButton":

  • показывается только в безопасных состояниях таймера

  • при клике вызывает askLeaveTimer, а не прямой переход

  • это защищает пользователя от потери прогресса 3. Механика выхода Кнопка не делает router.back() напрямую:

  • сначала проверяется состояние таймера

  • при необходимости открывается подтверждение выхода 4. Оверлей подтверждения выхода confirm-overlay появляется при v-if="confirmLeave":

  • затемняет фон и блокирует основной интерфейс

  • закрывается при клике вне карточки (@click.self) 5. Карточка подтверждения confirm-card содержит:

  • заголовок с вопросом «Выйти из таймера?»

  • пояснение, что прогресс будет сброшен

  • блок действий с двумя кнопками выбора 6. Кнопка «Нет»

  • вызывает closeLeaveModal

  • закрывает окно подтверждения

  • сохраняет текущее состояние таймера без изменений 7. Кнопка «Да»

  • вызывает confirmLeaveTimer

  • выполняет полный выход:

    • останавливает таймер

    • сбрасывает состояние

    • возвращает пользователя на предыдущий экран 8. UX-логика защиты прогресса Вся структура построена вокруг предотвращения случайного выхода:

  • прямой выход запрещён в активных состояниях

  • пользователь всегда получает подтверждение перед сбросом данных

  • интерфейс разделяет “навигацию” и “опасные действия”

Сейчас пользователь может в любой момент выйти из таймера, что делает использование приложения более удобным.


TimersList

Остался последний view, в котором мне нужно обновить часть кода. Мне нужно подключить кнопки к логике: «Редактировать» и «Удалить». Также необходимо добавить кнопку «Создать таймер», которую я размещу над списком таймеров.

TimersList.ts

В логику добавлю переходы для кнопок на соответствующие views. Также нужна логика обновления списка таймеров после удаления и при возврате на страницу списка после создания или редактирования таймера.

// Другой код/** ID таймера, ожидающего удаления */const confirmDeleteId = ref<number | null>(null)const router = useRouter()const route = useRoute()/** Переход на страницу конкретного таймера */const goToTimer = (id: number) => {  router.push(`/timer/${id}`)}/** Переход на создание нового таймера */const goToCreateTimer = () => {  router.push('/timer-update/new')}/** Переход на редактирование таймера */const goToEditTimer = (id: number) => {  router.push(`/timer-update/${id}`)}/** Открывает подтверждение удаления таймера */const askDeleteTimer = (id: number): void => {  confirmDeleteId.value = id}/** Отмена удаления таймера */const cancelDeleteTimer = (): void => {  confirmDeleteId.value = null}/** Удаляет таймер и обновляет список */const removeTimer = async (): Promise<void> => {  if (confirmDeleteId.value = null) {    return  }  try {    await deleteTimer(confirmDeleteId.value)    confirmDeleteId.value = null    await loadTimers()  } catch (e: unknown) {    error.value = e instanceof Error ? e.message : 'Ошибка удаления таймера'  }}// Другой код/** Перезагружает список при возврате на страницу /timers */watch(  () => route.fullPath,  (path) => {    if (path = '/timers') {      loadTimers()    }  },  { immediate: false })// Другой код

Разберём по шагам:

1. confirmDeleteId — хранение ID для удаления
confirmDeleteId хранит идентификатор таймера, который пользователь выбрал для удаления:

  • null → удаление не запрошено

  • число → открыт процесс подтверждения удаления 2. router и route — навигация и текущее состояние маршрута

  • router используется для программного перехода между страницами

  • route содержит информацию о текущем URL и параметрах маршрута 3. goToTimer — переход к таймеру Открывает страницу конкретного таймера:

  • принимает id

  • формирует маршрут /timer/{id}

  • выполняет переход через router.push 4. goToCreateTimer — создание нового таймера Перенаправляет пользователя на экран создания:

  • маршрут фиксированный /timer-update/new

  • используется единый экран формы в режиме создания 5. goToEditTimer — редактирование таймера Открывает форму редактирования:

  • принимает id

  • формирует маршрут /timer-update/{id}

  • загружает существующие данные таймера 6. askDeleteTimer — запрос удаления Инициирует процесс удаления:

  • сохраняет id таймера в confirmDeleteId

  • обычно вызывает открытие модального окна подтверждения 7. cancelDeleteTimer — отмена удаления Сбрасывает состояние удаления:

  • устанавливает confirmDeleteId = null

  • закрывает окно подтверждения

  • ничего не удаляет 8. removeTimer — удаление таймера Выполняет удаление из базы:

  • проверяет, что confirmDeleteId существует

  • вызывает deleteTimer (CRUD слой)

  • очищает состояние подтверждения

  • перезагружает список через loadTimers

  • обрабатывает ошибки и сохраняет сообщение в error 9. watch(route.fullPath) — реакция на навигацию Отслеживает смену маршрута:

  • если пользователь возвращается на /timers

  • автоматически перезагружает список таймеров

  • помогает поддерживать актуальные данные без ручного refresh

TimersList.html

Теперь добавим отображение новой логики в HTML.

<div  v-if="confirmDeleteId ! null"  class="confirm-overlay"  @click.self="cancelDeleteTimer">  <div class="confirm-card">    <h3>Удалить таймер?</h3>    <p>Это действие нельзя будет отменить.</p>    <div class="confirm-actions">      <button        class="btn btn-secondary"        type="button"        @click="cancelDeleteTimer"      >        Отмена      </button>      <button        class="btn btn-danger"        type="button"        @click="removeTimer"      >        Удалить      </button>    </div>  </div></div><button  @click="goToCreateTimer"  class="add-timer-button"  type="button">  <ion-icon :icon="addCircleOutline"></ion-icon>  Создать таймер</button>

Разберём по шагам:

1. Оверлей подтверждения удаления
Блок confirm-overlay появляется только если:

  • confirmDeleteId ! null

  • пользователь инициировал удаление таймера 2. Модальное окно удаления confirm-card показывает:

  • вопрос подтверждения «Удалить таймер?»

  • предупреждение о необратимости действия

  • кнопки выбора действия 3. Закрытие по клику вне окна @click.self="cancelDeleteTimer":

  • срабатывает только при клике на фон

  • не срабатывает при клике внутри карточки

  • закрывает модальное окно без удаления 4. Кнопка «Отмена»

  • вызывает cancelDeleteTimer

  • просто сбрасывает состояние удаления

  • пользователь остаётся на текущем экране 5. Кнопка «Удалить»

  • вызывает removeTimer

  • выполняет удаление из базы данных

  • после этого обновляет список таймеров 6. Кнопка создания таймера

  • вызывает goToCreateTimer

  • переводит пользователя на экран создания (/timer-update/new)

  • используется как основной CTA на странице списка 7. UX-логика интерфейса

  • удаление всегда требует подтверждения

  • случайное удаление невозможно

  • создание нового таймера доступно всегда

  • интерфейс разделяет опасные и безопасные действия

Новая логика добавлена. Теперь осталось только протестировать её вручную.


Проверка работы приложения

Мне очень интересно посмотреть, какой результат у меня получился. Для этого я запускаю приложение в режиме разработки, чтобы убедиться, что таймер корректно загружается, отображается и управляется через интерфейс.

cargo tauri dev

Чтобы проверить функциональность создания нового таймера, на экране «Таймеры» выбираю кнопку «Создать таймер».

Таймеры

Таймеры

Меня встречает экран «Конструктор таймера», который я постарался сделать максимально простым и понятным для пользователя. В поле «Название» нужно ввести имя таймера с клавиатуры, а остальные параметры настраиваются с помощью ползунков.

Использование ползунков гораздо удобнее, чем ручной ввод чисел или нажатие кнопок со стрелками вверх и вниз.

Кроме того, пользователь сразу видит допустимые пределы значений. Например, продолжительность помидора может составлять от 10 до 90 минут. Эти ограничения основаны на рекомендациях методики Pomodoro. Хотя, справедливости ради, даже 90 минут непрерывной концентрации на одной задаче — это довольно серьёзное испытание.

Создать таймер (нет данных)

Создать таймер (нет данных)

Для всех параметров, кроме названия, по умолчанию устанавливаются минимальные значения.

Что произойдёт, если не заполнить поле «Название»? Любая форма должна проверять обязательные поля перед отправкой данных. Для проверки сразу нажимаю кнопку «Сохранить и открыть».

Создать таймер (юзер не ввёл значение)

Создать таймер (юзер не ввёл значение)

Форма, как и ожидалось, отработала корректно: не позволила создать новый объект в базе данных и вывела под полем названия сообщение об ошибке красным цветом.

Так как в тестовых данных у меня уже есть минимальный и максимальный таймеры, я решил создать таймер со средними значениями. Для этого ввожу название, изменяю остальные параметры с помощью ползунков и нажимаю кнопку «Сохранить и открыть».

Создать таймер (данные введены)

Создать таймер (данные введены)

После сохранения меня сразу перенаправило на экран «Таймер». Обратите внимание: теперь у него появилась шапка с названием экрана и кнопкой «Назад». Благодаря этому пользователь может вернуться в конструктор таймера и создать ещё один при необходимости.

Таймер

Таймер

Теперь я хочу проверить работу кнопки «Назад» в ситуации, когда пользователь уже начал пользоваться таймером. Нажимаю кнопку «Старт», затем «Пауза», после чего выбираю «Назад».

Таймер (на паузе + нажата кнопка Назад)

Таймер (на паузе + нажата кнопка Назад)

Появилось всплывающее окно с предупреждением о том, что прогресс таймера будет сброшен. Если нажать «Нет», пользователь останется на текущем экране. Я же нажимаю «Да», чтобы выйти.

Таймеры

Таймеры

После этого я возвращаюсь на экран «Таймеры», где отображается недавно созданный таймер Timer middle. Раскрываю его и нажимаю кнопку «Редактировать».

Редактирование таймера

Редактирование таймера

Открывается форма редактирования, и, как и ожидалось, все данные выбранного таймера автоматически подгружаются. Поскольку используется тот же интерфейс, что и при создании таймера, повторно проверять его не буду и просто нажму кнопку «Назад».

Осталось проверить последнюю кнопку — «Удалить». Нажимаю её для проверки процесса удаления.

Удаление таймера

Удаление таймера

В открывшемся окне можно либо подтвердить удаление, либо отменить его. Это защищает пользователя от случайных нажатий, например ребёнком или даже котом.

На этом ручная проверка нового функционала завершена. Основную функциональность таймера я считаю реализованной. Однако у меня уже есть много идей по дальнейшему развитию приложения.


Что дальше?

Теперь, когда основная функциональность готова, пользователь может полноценно использовать таймеры Pomodoro. Однако приложению всё ещё не хватает некоторых возможностей, которые делают работу с ним более удобной. Например, пользователю необходимы настройки, позволяющие гибко адаптировать программу под свои предпочтения.

В следующей статье я реализую следующие настройки:

  • Тема — возможность выбора между светлой и тёмной темами оформления;

  • Выбор звука — добавлю несколько встроенных вариантов, а также возможность использовать собственную мелодию;

  • О программе — размещу информацию о приложении, репозитории проекта и авторе.

После этого можно будет приступить к написанию тестов для MVP 1.

Если у вас есть мысли о том, как можно улучшить проект, пишите в комментариях — с удовольствием ознакомлюсь с вашими предложениями!

Читайте продолжение — не пропустите!


Ссылки к статье

ссылка на оригинал статьи https://habr.com/ru/articles/1044224/