ESPN Deportes¿Qué sabemos de la pelea Canelo Álvarez vs Mbili?ESPNNC State dropped the ball -- and ended up in the Bottom 10The Jerusalem PostHope for progress after US, Iran hold first shuttle talks in monthsRTP DesportoTorreense adianta-se ao Metalist 1925 com um `bis` de Lakip na Taça Europa feminina3DNewsTitan Quest 2 не вырвется из раннего доступа Steam и Epic Games Store в 2026 году — новый трейлер и дата выхода версии 1.07sur7Pascal, victime d’une arnaque après un achat sur Amazon: “J’avais 168.000 euros, il m’en reste 80”Complete SportsPremier League Panel Rules Sunderland Penalty Call vs Arsenal IncorrectDeadlineTaylor Swift Announces Another Three New Songs For Release FridayGMA NewsPCSO Lotto Results: No winners of major jackpot draws on September 23, 2026Global NewsVancouver police say $5.8M of cocaine found in t-shirt order is record bustFootball ItaliaComputer predicts Serie A 2026/27 winners, top 4 and doomed to relegationVilaWebLa Mercè 2026: totes les activitats de cultura popular
The Daily Newsstand · Free, Always
Wednesday, September 23, 2026

Как я спроектировал офлайн‑приложение для заметок под будущую синхронизацию — и собрал лендинг без единой сборки

Translate

Полгода назад я в одиночку начал делать Nookly — локальное приложение для заметок на Flutter: страницы, блочный редактор, таблицы с канбан‑видом, полнотекстовый поиск. Ключевое требование с самого начала: всё работает офлайн, данные не покидают устройство. В этом посте — не про сам продукт, а про два инженерных решения, которые могут быть полезны: как спроектировать схему БД так, чтобы синхронизация потом не потребовала переписывания половины кода, и как собрать лендинг вообще без шага сборки.

Часть 1: схема БД, готовая к синхронизации, которой ещё нет

Синхронизации между устройствами в Nookly пока нет — она в планах. Но если закладывать её задним числом, придётся трогать каждую таблицу и почти весь data‑слой. Поэтому с первого дня каждая таблица несёт четыре поля через общий миксин:

mixin SyncColumns on Table {
  TextColumn get id => text().clientDefault(() => uuid.v4())();
  DateTimeColumn get createdAt => dateTime().clientDefault(() => DateTime.now())();
  DateTimeColumn get updatedAt => dateTime().clientDefault(() => DateTime.now())();
  BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
  IntColumn get version => integer().withDefault(const Constant(1))();
}
  • id как UUID, а не autoincrement — исключает коллизии между устройствами, которые неизбежны при last‑write‑wins или CRDT‑подобной синхронизации.

  • isDeleted вместо DELETE — soft‑delete нужен, чтобы факт удаления можно было разослать на другие устройства при синка; жёсткий DELETE эту информацию просто стирает.

  • version, инкрементируемый при каждой записи — база для разрешения конфликтов (пока не используется, но место уже есть).

Репозитории объявлены интерфейсами в domain‑слое и ничего не знают про Drift — это значит, что data‑слой в теории заменяем на синхронизируемый бэкенд без изменений в UI и бизнес‑логике.

Часть 2: порядок элементов без переиндексации

Drag‑and‑drop для страниц и блоков — частая операция, и наивная реализация (целочисленный order, инкремент/декремент соседей при каждом перемещении) на каждый drag долбит по половине таблицы UPDATE‑запросами.

Вместо этого — дробные индексы. Вставка элемента между соседями — это просто среднее арифметическое их orderIndex:

double between(double before, double after) => (before + after) / 2;

Переставили элемент между позициями 1.0 и 2.0 — новый получает 1.5. Ещё раз между 1.0 и 1.5 — 1.25. Соседние записи никогда не трогаются. Единственный практический нюанс — числа с плавающей точкой не бесконечно делимы, и после многих перестановок в одном месте точность может начать деградировать; для реального продакшена стоит закладывать периодическую ребалансировку индексов всей коллекции, если планируется очень интенсивный реордеринг.

Часть 3: поиск без обслуживания вручную

Полнотекстовый поиск сделан на SQLite FTS5 — виртуальной таблице с индексом. Обычная головная боль с FTS‑индексами — их рассинхронизация с основными данными: обновили страницу, а индекс не обновился. Решение — не трогать это руками вообще, а повесить SQL‑триггеры прямо на таблицы:

CREATE TRIGGER pages_ai AFTER INSERT ON pages BEGIN
  INSERT INTO search_index(rowid, title) VALUES (new.rowid, new.title);
END;

CREATE TRIGGER pages_au AFTER UPDATE ON pages BEGIN
  UPDATE search_index SET title = new.title WHERE rowid = new.rowid;
END;

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

Часть 4: лендинг без единого шага сборки

Отдельная маленькая задача — сделать сайт для проекта. Здесь всё наоборот: не production‑приложение, а один статический файл, который должен просто открыться в браузере и на любом хостинге без npm install / webpack / vite.

Решение — React, ReactDOM и Babel Standalone прямо из CDN:

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>

<script type="text/babel">
  function App(){ return <div className="text-3xl font-bold">Hello</div>; }
  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>

JSX компилируется прямо в браузере на лету через Babel Standalone. Tailwind подключён через Play CDN — он сканирует DOM через MutationObserver и на лету генерирует нужные классы, поэтому работает даже с динамически рендерящимся через React деревом.

Очевидный минус — Babel‑транспиляция в рантайме не бесплатна, и для чего‑то крупнее одностраничника это станет заметно на слабых устройствах. Но для лендинга это разумный компромен: ноль конфигурации, деплой — это буквально скопировать один файл.

Интерактив на странице сделан не ради эффекта, а завязан на реальные фичи: переключатель темы меняет между двумя настоящими скриншотами одного и того же экрана в светлом/тёмном режиме (то же поведение, что в самом приложении), а блок поиска — рабочий фильтр по небольшому набору данных с подсветкой совпадений, повторяющий логику Ctrl+K в приложении.

Итог

Ни одно из решений выше не уникально само по себе — сочетание UUID + soft‑delete + version для будущей синхронизации, дробные индексы для DnD и триггеры для FTS есть в литературе по локально‑first приложениям. Но именно потому, что они довольно рутинные и почти всегда всплывают заново в подобных проектах, показалось, что стоит выписать их в одном месте.

Само приложение — Nookly, исходники репозитория‑витрины и релизы — на GitHub.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.