Полгода назад я в одиночку начал делать 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.