Init
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"PowerShell(flutter *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
- platform: android
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Каркас Flutter-приложения для учёта личных финансов (Android)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Стартует новый проект (`C:\Sanders\Flutter\NewBudget` — пустая директория, Flutter 3.35.1 / Dart 3.9.0).
|
||||||
|
Цель — заложить **качественную структуру каркаса (скелета) без реализации фич**: сущности, таблицы,
|
||||||
|
контракты репозиториев, провайдеры, роутинг, тема, заглушки экранов. После этого `flutter run` должен
|
||||||
|
запускаться и показывать плейсхолдер-экраны, а добавление реальной логики сводилось бы к заполнению
|
||||||
|
заранее подготовленных слоёв.
|
||||||
|
|
||||||
|
Решения, согласованные с пользователем:
|
||||||
|
- **БД:** Drift (SQLite) — реляционные связи account→transaction→category, реактивные стримы, миграции, SQL-агрегаты.
|
||||||
|
- **Riverpod:** кодогенерация (`@riverpod` + `riverpod_generator` + `build_runner`).
|
||||||
|
- **Пользователи:** несколько локальных профилей; все доменные таблицы ссылаются на `userId`.
|
||||||
|
- **Архитектура:** чёткое разделение отображения (UI) от логики работы (см. ниже).
|
||||||
|
|
||||||
|
## Архитектура: разделение UI и логики
|
||||||
|
|
||||||
|
Каркас строится по **feature-first** с явными слоями внутри каждой фичи. Зависимости направлены строго
|
||||||
|
внутрь (presentation → application → domain ← data), внешние слои не знают о Drift:
|
||||||
|
|
||||||
|
```
|
||||||
|
presentation → application → domain ← data
|
||||||
|
(UI) (логика) (контракты) (Drift+реализации)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **presentation** — только Widgets/экраны. Читает состояние через `ref.watch(...)`, вызывает методы
|
||||||
|
контроллеров. **Не содержит** бизнес-логики, не знает про Drift, не делает запросов к БД.
|
||||||
|
- **application** — Riverpod-контроллеры (`@riverpod` Notifier/AsyncNotifier). Здесь живёт логика:
|
||||||
|
валидация, оркестрация вызовов репозиториев, формирование состояния для UI. Зависит только от
|
||||||
|
абстракций `domain`.
|
||||||
|
- **domain** — чистый Dart: сущности (`User`, `Account`, ...) и **абстрактные** интерфейсы репозиториев.
|
||||||
|
Никаких зависимостей от Flutter/Drift. Это контракт между логикой и данными.
|
||||||
|
- **data** — реализации репозиториев + Drift (таблицы, DAO, мапперы row↔entity). Только здесь пишется SQL.
|
||||||
|
|
||||||
|
Так «фронт» (presentation) физически отделён от «логики» (application) и от «данных» (data): UI можно
|
||||||
|
менять, не трогая логику; источник данных (Drift) можно заменить, не трогая UI и логику — достаточно дать
|
||||||
|
новую реализацию интерфейса из `domain`. Слой usecase-классов сознательно **опускаем** — для приложения
|
||||||
|
такого размера контроллеры вызывают репозитории напрямую (прагматичный baseline, без лишних абстракций).
|
||||||
|
|
||||||
|
## Структура каталогов
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
main.dart # точка входа: runApp(ProviderScope(child: App()))
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
app.dart # MaterialApp.router, тема, локализация
|
||||||
|
router/
|
||||||
|
app_router.dart # go_router (провайдер конфигурации)
|
||||||
|
app_routes.dart # константы путей/имён
|
||||||
|
theme/
|
||||||
|
app_theme.dart # light/dark ThemeData
|
||||||
|
app_colors.dart
|
||||||
|
core/ # инфраструктура, без бизнес-логики фич
|
||||||
|
database/
|
||||||
|
app_database.dart # @DriftDatabase, schemaVersion, миграции (stub)
|
||||||
|
tables/ # users/accounts/categories/transactions (Drift Tables)
|
||||||
|
daos/ # *_dao.dart — реактивные запросы (watch/insert/update)
|
||||||
|
converters/ # TypeConverter для enum, денег, дат
|
||||||
|
providers/
|
||||||
|
database_provider.dart # @Riverpod(keepAlive) AppDatabase
|
||||||
|
money/
|
||||||
|
money.dart # хранение в минорных единицах (int), форматирование
|
||||||
|
errors/
|
||||||
|
failures.dart
|
||||||
|
constants/
|
||||||
|
features/
|
||||||
|
user/
|
||||||
|
domain/
|
||||||
|
entities/user.dart
|
||||||
|
repositories/user_repository.dart # abstract
|
||||||
|
data/
|
||||||
|
mappers/user_mapper.dart
|
||||||
|
repositories/user_repository_impl.dart # реализация поверх UsersDao
|
||||||
|
application/
|
||||||
|
user_providers.dart # провайдер репозитория (DI)
|
||||||
|
active_user_controller.dart # текущий выбранный профиль
|
||||||
|
users_controller.dart # список/создание профилей
|
||||||
|
presentation/
|
||||||
|
screens/ # заглушки
|
||||||
|
widgets/
|
||||||
|
settings/ # (та же структура: настройки на профиль — валюта, тема, локаль)
|
||||||
|
accounts/ # (та же структура)
|
||||||
|
categories/ # (та же структура)
|
||||||
|
transactions/ # (та же структура)
|
||||||
|
shared/
|
||||||
|
widgets/ # общие виджеты (AppScaffold, EmptyState, ...)
|
||||||
|
formatters/ # форматирование валюты/дат (intl)
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая фича повторяет один и тот же шаблон `domain/ data/ application/ presentation/`. В скелете методы
|
||||||
|
репозиториев/DAO определены сигнатурами, реализации минимальны или содержат `TODO`/`UnimplementedError`,
|
||||||
|
экраны — плейсхолдеры.
|
||||||
|
|
||||||
|
## Модель данных (Drift)
|
||||||
|
|
||||||
|
Деньги хранятся как **целые минорные единицы** (копейки/центы) в `int` — чтобы избежать ошибок float.
|
||||||
|
Идентификаторы — `int autoIncrement` (просто для локального оффлайна; для будущей облачной синхронизации
|
||||||
|
можно перейти на UUID/text — отмечено как развилка). Все доменные таблицы имеют `userId` (FK → users).
|
||||||
|
|
||||||
|
- **users**: `id`, `name`, `createdAt`. Активный профиль хранится отдельно (настройка), не флагом в строке.
|
||||||
|
- **settings** (на пользователя): `userId` (FK), `baseCurrency`, `themeMode` (enum), `locale`,
|
||||||
|
`firstDayOfMonth`. Хранит «активного пользователя» — либо отдельная key-value таблица `app_preferences`.
|
||||||
|
- **accounts**: `id`, `userId` (FK), `name`, `type` (enum: cash/card/bank/savings), `currency`,
|
||||||
|
`initialBalance` (int), `iconCode`, `colorValue`, `archived`, `createdAt`.
|
||||||
|
- **categories**: `id`, `userId` (FK), `name`, `type` (enum: income/expense), `iconCode`, `colorValue`,
|
||||||
|
`parentId` (nullable, для подкатегорий), `archived`.
|
||||||
|
- **transactions**: `id`, `userId` (FK), `accountId` (FK), `categoryId` (FK, nullable),
|
||||||
|
`type` (enum: income/expense/transfer), `amount` (int, минорные единицы), `date`, `note` (nullable),
|
||||||
|
`transferToAccountId` (nullable, для переводов), `createdAt`.
|
||||||
|
|
||||||
|
Для каждой таблицы — соответствующая чистая сущность в `domain/entities` (immutable, через `freezed`) и
|
||||||
|
маппер в `data/mappers`. Enum'ы кодируются Drift `TypeConverter`'ами в `core/database/converters`.
|
||||||
|
|
||||||
|
DAO (`core/database/daos`) предоставляют реактивные методы (`Stream` через `.watch()`), например:
|
||||||
|
`watchAccountsByUser(userId)`, `watchTransactions(filter)`, агрегаты `watchAccountBalance(accountId)`,
|
||||||
|
`watchTotalsByCategory(period)`. В скелете — сигнатуры + базовые запросы, сложные агрегаты как `TODO`.
|
||||||
|
|
||||||
|
## Зависимости (pubspec.yaml)
|
||||||
|
|
||||||
|
Runtime:
|
||||||
|
- `flutter_riverpod`, `riverpod_annotation`
|
||||||
|
- `drift`, `drift_flutter` (открытие БД на Android, путь через path_provider под капотом)
|
||||||
|
- `go_router`
|
||||||
|
- `intl` (форматирование валюты/дат)
|
||||||
|
- `freezed_annotation` (immutable-сущности)
|
||||||
|
|
||||||
|
Dev:
|
||||||
|
- `build_runner`
|
||||||
|
- `riverpod_generator`, `riverpod_lint`, `custom_lint`
|
||||||
|
- `drift_dev`
|
||||||
|
- `freezed`
|
||||||
|
- `flutter_lints` (или `very_good_analysis`)
|
||||||
|
|
||||||
|
`analysis_options.yaml` подключает `custom_lint` (для riverpod_lint) и исключает `*.g.dart`/`*.freezed.dart`
|
||||||
|
из анализа.
|
||||||
|
|
||||||
|
## Последовательность сборки каркаса
|
||||||
|
|
||||||
|
1. `flutter create . --org com.example --platforms=android` в текущей директории (генерирует Android-обвязку).
|
||||||
|
2. Прописать зависимости в `pubspec.yaml`, `flutter pub get`.
|
||||||
|
3. `core/database`: таблицы → конвертеры → `AppDatabase` (schemaVersion=1, пустая стратегия миграций) → DAO.
|
||||||
|
4. `core/providers/database_provider.dart` — провайдер `AppDatabase` (keepAlive).
|
||||||
|
5. По каждой фиче, по шаблону: `domain` (entity + abstract repo) → `data` (mapper + repo impl на DAO) →
|
||||||
|
`application` (провайдер репозитория + контроллеры) → `presentation` (экраны-заглушки).
|
||||||
|
6. `app/`: тема, `go_router` (маршруты: выбор профиля, дашборд, счета, категории, транзакции,
|
||||||
|
добавление/редактирование транзакции, настройки), `app.dart`, `main.dart`.
|
||||||
|
7. `dart run build_runner build --delete-conflicting-outputs` — генерация `*.g.dart` / `*.freezed.dart`.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `flutter pub get` и `dart run build_runner build` проходят без ошибок (кодогенерация Drift+Riverpod+Freezed).
|
||||||
|
- `flutter analyze` — без ошибок (с учётом исключений для сгенерированных файлов).
|
||||||
|
- `flutter run` на Android-эмуляторе/устройстве: приложение запускается, открывается стартовый экран
|
||||||
|
(выбор/создание профиля → дашборд), навигация между экранами-заглушками работает, БД инициализируется
|
||||||
|
без падений.
|
||||||
|
- Каркас считается готовым, когда добавление реальной фичи требует только: запрос в DAO → метод в repo impl →
|
||||||
|
метод в контроллере → отображение в экране, не затрагивая остальные слои.
|
||||||
|
|
||||||
|
## Открытые развилки (на будущее, вне скелета)
|
||||||
|
|
||||||
|
- ID: `int autoIncrement` сейчас vs `UUID/text` при появлении облачной синхронизации.
|
||||||
|
- «Активный пользователь»: отдельная таблица `app_preferences` vs `shared_preferences`.
|
||||||
|
- Переводы между счетами: одна запись с `transferToAccountId` vs парные транзакции.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# new_budget
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||||
|
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
analyzer:
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
exclude:
|
||||||
|
- "**/*.g.dart"
|
||||||
|
- "**/*.freezed.dart"
|
||||||
|
errors:
|
||||||
|
invalid_annotation_target: ignore
|
||||||
|
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_single_quotes: true
|
||||||
|
avoid_print: true
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.sanders.budget.new_budget"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.sanders.budget.new_budget"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="new_budget"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.sanders.budget.new_budget
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
# This newDsl flag was added by the Flutter template
|
||||||
|
android.newDsl=false
|
||||||
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
|
android.builtInKotlin=false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath =
|
||||||
|
run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "9.0.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
|
||||||
|
// Android.jsx — Simplified Android (Material 3) device frame
|
||||||
|
// Status bar + top app bar + content + gesture nav + keyboard.
|
||||||
|
// Based on Figma M3 spec. No dependencies, no image assets.
|
||||||
|
|
||||||
|
const MD_C = {
|
||||||
|
surface: '#f4fbf8',
|
||||||
|
surfaceVariant: '#dae5e1',
|
||||||
|
inverseOnSurface: '#ecf2ef',
|
||||||
|
secondaryContainer: '#cde8e1',
|
||||||
|
primaryFixedDim: '#83d5c6',
|
||||||
|
onSurface: '#171d1b',
|
||||||
|
onSurfaceVar: '#49454f',
|
||||||
|
onPrimaryContainer: '#00201c',
|
||||||
|
primary: '#006a60',
|
||||||
|
frameBorder: 'rgba(116,119,117,0.5)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Status bar (time left, wifi/cell/battery right)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidStatusBar({ dark = false }) {
|
||||||
|
const c = dark ? '#fff' : MD_C.onSurface;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: 40, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'space-between', padding: '0 16px',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>
|
||||||
|
{/* time left */}
|
||||||
|
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
|
||||||
|
</div>
|
||||||
|
{/* camera punch-hole (center) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
|
||||||
|
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
|
||||||
|
}} />
|
||||||
|
{/* status icons right */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', paddingRight: 2 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
|
||||||
|
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
|
||||||
|
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
|
||||||
|
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Top app bar (Material 3 small/medium)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidAppBar({ title = 'Title', large = false }) {
|
||||||
|
const iconDot = (
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 22, height: 22, borderRadius: '50%', background: MD_C.onSurfaceVar, opacity: 0.3 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{ background: MD_C.surface, padding: '4px 4px 0' }}>
|
||||||
|
<div style={{ height: 56, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{iconDot}
|
||||||
|
{!large && (
|
||||||
|
<span style={{
|
||||||
|
flex: 1, fontSize: 22, fontWeight: 400, color: MD_C.onSurface,
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>{title}</span>
|
||||||
|
)}
|
||||||
|
{large && <div style={{ flex: 1 }} />}
|
||||||
|
{iconDot}
|
||||||
|
</div>
|
||||||
|
{large && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 16px 20px',
|
||||||
|
fontSize: 28, fontWeight: 400, color: MD_C.onSurface,
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>{title}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// List item (Material 3)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidListItem({ headline, supporting, leading }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 16,
|
||||||
|
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>
|
||||||
|
{leading && (
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: '50%',
|
||||||
|
background: MD_C.primary, color: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 18, fontWeight: 500, flexShrink: 0,
|
||||||
|
}}>{leading}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
|
||||||
|
{supporting && (
|
||||||
|
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Gesture nav bar (pill)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidNavBar({ dark = false }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 108, height: 4, borderRadius: 2,
|
||||||
|
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Device frame — wraps everything
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidDevice({
|
||||||
|
children, width = 412, height = 892, dark = false,
|
||||||
|
title, large = false, keyboard = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width, height, borderRadius: 18, overflow: 'hidden',
|
||||||
|
background: dark ? '#1d1b20' : MD_C.surface,
|
||||||
|
border: `8px solid ${MD_C.frameBorder}`,
|
||||||
|
boxShadow: '0 30px 80px rgba(0,0,0,0.25)',
|
||||||
|
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
|
||||||
|
}}>
|
||||||
|
<AndroidStatusBar dark={dark} />
|
||||||
|
{title !== undefined && <AndroidAppBar title={title} large={large} />}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{keyboard && <AndroidKeyboard />}
|
||||||
|
<AndroidNavBar dark={dark} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Keyboard — Gboard (Material 3)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidKeyboard() {
|
||||||
|
let _k = 0;
|
||||||
|
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
|
||||||
|
<div key={_k++} style={{
|
||||||
|
height: 46, borderRadius: r, flex, minWidth: minW,
|
||||||
|
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: 'Roboto, system-ui', fontSize: fs,
|
||||||
|
color: MD_C.onPrimaryContainer,
|
||||||
|
}}>{l}</div>
|
||||||
|
);
|
||||||
|
const row = (keys, style = {}) => (
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
|
||||||
|
{keys.map(l => key(l))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
|
}}>
|
||||||
|
{/* navbar spacer (icons omitted) */}
|
||||||
|
<div style={{ height: 44 }} />
|
||||||
|
{/* key rows */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||||
|
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{key('', { bg: MD_C.surfaceVariant })}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
|
||||||
|
{['z','x','c','v','b','n','m'].map(l => key(l))}
|
||||||
|
</div>
|
||||||
|
{key('', { bg: MD_C.surfaceVariant })}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
|
||||||
|
{key(',', { bg: MD_C.surfaceVariant })}
|
||||||
|
{key('', { flex: 3, minW: 154 })}
|
||||||
|
{key('.', { bg: MD_C.surfaceVariant })}
|
||||||
|
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// Shared bits for budget app wireframes
|
||||||
|
// Tokens come from CSS vars set on .wf-root in index.html so dark/light can swap.
|
||||||
|
|
||||||
|
// ─── Icons (thin line, 24px stroke 1.5) ────────────────────────────
|
||||||
|
const Ico = ({ d, size = 20, stroke = 1.5, fill = 'none', style }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}
|
||||||
|
stroke="currentColor" strokeWidth={stroke}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" style={style}>
|
||||||
|
{d}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icons = {
|
||||||
|
search: <Ico d={<><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></>} />,
|
||||||
|
bell: <Ico d={<><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 9H3c0-2 3-2 3-9z" /><path d="M10 21a2 2 0 0 0 4 0" /></>} />,
|
||||||
|
menu: <Ico d={<><path d="M4 7h16M4 12h16M4 17h16"/></>} />,
|
||||||
|
chev: <Ico d={<><path d="m6 9 6 6 6-6"/></>} />,
|
||||||
|
chevRt: <Ico d={<><path d="m9 6 6 6-6 6"/></>} size={16} />,
|
||||||
|
plus: <Ico d={<><path d="M12 5v14M5 12h14"/></>} stroke={2} />,
|
||||||
|
filter: <Ico d={<><path d="M4 5h16M7 12h10M10 19h4"/></>} />,
|
||||||
|
home: <Ico d={<><path d="M4 10 12 4l8 6v9a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1z"/></>} />,
|
||||||
|
stats: <Ico d={<><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></>} />,
|
||||||
|
wallet: <Ico d={<><rect x="3" y="6" width="18" height="14" rx="2"/><path d="M16 13h2M3 10h18"/></>} />,
|
||||||
|
user: <Ico d={<><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></>} />,
|
||||||
|
card: <Ico d={<><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h3"/></>} />,
|
||||||
|
cash: <Ico d={<><rect x="2" y="6" width="20" height="12" rx="1"/><circle cx="12" cy="12" r="3"/></>} />,
|
||||||
|
bank: <Ico d={<><path d="M3 10h18L12 3 3 10z"/><path d="M5 10v8M9 10v8M15 10v8M19 10v8M3 21h18"/></>} />,
|
||||||
|
pig: <Ico d={<><path d="M4 13a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v2a4 4 0 0 1-4 4h-1l-1 2h-2l-1-2H9l-1 2H6l-1-2a4 4 0 0 1-1-3v-1z"/><circle cx="16" cy="13" r=".7" fill="currentColor"/></>} />,
|
||||||
|
food: <Ico d={<><path d="M5 3v8a3 3 0 0 0 6 0V3M8 11v10M16 3c-2 2-2 6 0 8v10"/></>} />,
|
||||||
|
cart: <Ico d={<><path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.2a2 2 0 0 0 2-1.5L21 8H6"/><circle cx="9" cy="20" r="1.2"/><circle cx="18" cy="20" r="1.2"/></>} />,
|
||||||
|
car: <Ico d={<><path d="M3 14l2-6a2 2 0 0 1 2-1.5h10a2 2 0 0 1 2 1.5l2 6v4H3v-4z"/><circle cx="7.5" cy="17" r="1.3"/><circle cx="16.5" cy="17" r="1.3"/></>} />,
|
||||||
|
house: <Ico d={<><path d="M4 11 12 4l8 7v9h-5v-6H9v6H4z"/></>} />,
|
||||||
|
film: <Ico d={<><rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 9h18M3 15h18M7 5v14M17 5v14"/></>} />,
|
||||||
|
health: <Ico d={<><path d="M12 4v16M4 12h16"/></>} />,
|
||||||
|
gift: <Ico d={<><rect x="3" y="8" width="18" height="5"/><path d="M12 8v13M3 13h18v8H3zM7 8a3 3 0 1 1 5-2 3 3 0 1 1 5 2"/></>} />,
|
||||||
|
more: <Ico d={<><circle cx="6" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="18" cy="12" r="1.2" fill="currentColor"/></>} />,
|
||||||
|
arrowUp: <Ico d={<><path d="M7 17 17 7M9 7h8v8"/></>} size={14} />,
|
||||||
|
arrowDn: <Ico d={<><path d="M7 7l10 10M9 17h8V9"/></>} size={14} />,
|
||||||
|
eye: <Ico d={<><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Caveat annotation tag (handdrawn comment) ─────────────────────
|
||||||
|
function Note({ children, style }) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'Caveat, cursive', fontSize: 15, lineHeight: 1,
|
||||||
|
color: 'var(--accent)', letterSpacing: 0.2,
|
||||||
|
...style,
|
||||||
|
}}>{children}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small arrow used with Note callouts
|
||||||
|
function NoteArrow({ rot = 0, len = 28, style }) {
|
||||||
|
return (
|
||||||
|
<svg width={len + 8} height={20} viewBox={`0 0 ${len + 8} 20`}
|
||||||
|
style={{ transform: `rotate(${rot}deg)`, ...style }}>
|
||||||
|
<path d={`M2 10 Q ${len * 0.5} 2 ${len} 12`} fill="none"
|
||||||
|
stroke="var(--accent)" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
<path d={`M${len - 5} 8 L${len + 2} 12 L${len - 3} 14`} fill="none"
|
||||||
|
stroke="var(--accent)" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Money formatting ──────────────────────────────────────────────
|
||||||
|
const fmt = (n) => {
|
||||||
|
const s = Math.abs(n).toLocaleString('ru-RU').replace(/,/g, ' ');
|
||||||
|
return (n < 0 ? '−' : '') + s + ' ₽';
|
||||||
|
};
|
||||||
|
const fmtNoCur = (n) => {
|
||||||
|
return Math.abs(n).toLocaleString('ru-RU').replace(/,/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Donut chart with hover ────────────────────────────────────────
|
||||||
|
function Donut({ data, size = 180, thickness = 26, active = null, onSegment }) {
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
const r = size / 2;
|
||||||
|
const ri = r - thickness;
|
||||||
|
let a0 = -Math.PI / 2;
|
||||||
|
const arcs = data.map((d, i) => {
|
||||||
|
const sweep = (d.value / total) * Math.PI * 2;
|
||||||
|
const a1 = a0 + sweep;
|
||||||
|
const big = sweep > Math.PI ? 1 : 0;
|
||||||
|
const isActive = active === i;
|
||||||
|
const rOff = isActive ? 4 : 0;
|
||||||
|
const ro = r + rOff;
|
||||||
|
const rii = ri + rOff;
|
||||||
|
const x0 = r + ro * Math.cos(a0), y0 = r + ro * Math.sin(a0);
|
||||||
|
const x1 = r + ro * Math.cos(a1), y1 = r + ro * Math.sin(a1);
|
||||||
|
const x2 = r + rii * Math.cos(a1), y2 = r + rii * Math.sin(a1);
|
||||||
|
const x3 = r + rii * Math.cos(a0), y3 = r + rii * Math.sin(a0);
|
||||||
|
const path = `M${x0},${y0} A${ro},${ro} 0 ${big} 1 ${x1},${y1} L${x2},${y2} A${rii},${rii} 0 ${big} 0 ${x3},${y3} Z`;
|
||||||
|
a0 = a1;
|
||||||
|
return { path, color: d.color, i };
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
{arcs.map(a => (
|
||||||
|
<path key={a.i} d={a.path} fill={a.color}
|
||||||
|
opacity={active === null || active === a.i ? 1 : 0.35}
|
||||||
|
onClick={() => onSegment && onSegment(a.i)}
|
||||||
|
style={{ cursor: onSegment ? 'pointer' : 'default', transition: 'opacity .2s' }} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stacked horizontal bar — alt visualization
|
||||||
|
function StackBar({ data, height = 16, radius = 8 }) {
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
let acc = 0;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height, borderRadius: radius, overflow: 'hidden',
|
||||||
|
display: 'flex', background: 'var(--line)',
|
||||||
|
}}>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const w = (d.value / total) * 100;
|
||||||
|
const el = (
|
||||||
|
<div key={i} title={d.label}
|
||||||
|
style={{ width: `${w}%`, background: d.color, height: '100%' }} />
|
||||||
|
);
|
||||||
|
acc += w;
|
||||||
|
return el;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Category palette (sage/terracotta family — calm) ───────────────
|
||||||
|
const CATS = [
|
||||||
|
{ id: 'food', label: 'Продукты', icon: Icons.cart, color: '#8aa6a0' },
|
||||||
|
{ id: 'rent', label: 'Жильё', icon: Icons.house, color: '#c89a86' },
|
||||||
|
{ id: 'transp', label: 'Транспорт', icon: Icons.car, color: '#b3a589' },
|
||||||
|
{ id: 'cafe', label: 'Кафе', icon: Icons.food, color: '#9fb38a' },
|
||||||
|
{ id: 'enter', label: 'Досуг', icon: Icons.film, color: '#a99cb9' },
|
||||||
|
{ id: 'other', label: 'Другое', icon: Icons.more, color: '#b8b5ac' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock month spend by category
|
||||||
|
const SPEND = [
|
||||||
|
{ id: 'food', value: 14_200 },
|
||||||
|
{ id: 'rent', value: 32_000 },
|
||||||
|
{ id: 'transp', value: 5_600 },
|
||||||
|
{ id: 'cafe', value: 8_400 },
|
||||||
|
{ id: 'enter', value: 4_200 },
|
||||||
|
{ id: 'other', value: 2_800 },
|
||||||
|
];
|
||||||
|
const SPEND_TOTAL = SPEND.reduce((s, d) => s + d.value, 0);
|
||||||
|
const DONUT_DATA = SPEND.map(s => {
|
||||||
|
const c = CATS.find(c => c.id === s.id);
|
||||||
|
return { value: s.value, color: c.color, label: c.label, id: s.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Accounts ──────────────────────────────────────────────────────
|
||||||
|
const ACCOUNTS = [
|
||||||
|
{ id: 'all', label: 'Все счета', short: 'Все', icon: Icons.wallet, balance: 184_320 },
|
||||||
|
{ id: 'card', label: 'Карта', short: 'Карта', icon: Icons.card, balance: 142_500 },
|
||||||
|
{ id: 'cash', label: 'Наличные', short: 'Кэш', icon: Icons.cash, balance: 12_820 },
|
||||||
|
{ id: 'save', label: 'Копилка', short: 'Копилка', icon: Icons.pig, balance: 29_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Transactions (compact list) ────────────────────────────────────
|
||||||
|
const TX = [
|
||||||
|
{ id: 1, cat: 'food', merchant: 'Лента', acc: 'card', amount: -2_340, when: 'Сегодня, 19:42' },
|
||||||
|
{ id: 2, cat: 'cafe', merchant: 'Кофе Хауз', acc: 'card', amount: -480, when: 'Сегодня, 09:15' },
|
||||||
|
{ id: 3, cat: 'transp', merchant: 'Метро', acc: 'card', amount: -62, when: 'Сегодня, 08:50' },
|
||||||
|
{ id: 4, cat: 'food', merchant: 'Перекрёсток', acc: 'cash', amount: -1_120, when: 'Вчера, 21:08' },
|
||||||
|
{ id: 5, cat: 'enter', merchant: 'Кинотеатр', acc: 'card', amount: -650, when: 'Вчера, 19:30' },
|
||||||
|
{ id: 6, cat: 'rent', merchant: 'Аренда квартиры',acc: 'card', amount: -32_000,when: '21 мая' },
|
||||||
|
{ id: 7, cat: 'other', merchant: 'Зарплата', acc: 'card', amount: 95_000, when: '20 мая' },
|
||||||
|
{ id: 8, cat: 'transp', merchant: 'Яндекс Такси', acc: 'card', amount: -340, when: '20 мая' },
|
||||||
|
{ id: 9, cat: 'cafe', merchant: 'Шоколадница', acc: 'cash', amount: -720, when: '19 мая' },
|
||||||
|
{ id: 10, cat: 'food', merchant: 'Магнит', acc: 'card', amount: -890, when: '19 мая' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Compact transaction row ───────────────────────────────────────
|
||||||
|
function TxRow({ tx, dense = true }) {
|
||||||
|
const cat = CATS.find(c => c.id === tx.cat) || CATS[CATS.length - 1];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: dense ? '8px 16px' : '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 8,
|
||||||
|
background: cat.color + '26', color: cat.color,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>{cat.icon}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 14, color: 'var(--ink)', fontWeight: 500,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>{tx.merchant}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-2)', display: 'flex', gap: 6 }}>
|
||||||
|
<span>{cat.label}</span><span>·</span><span>{tx.when}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
fontSize: 14, fontVariantNumeric: 'tabular-nums',
|
||||||
|
color: tx.amount > 0 ? 'var(--pos)' : 'var(--ink)',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{tx.amount > 0 ? '+' : '−'}{fmtNoCur(tx.amount)} ₽
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Day group header ──────────────────────────────────────────────
|
||||||
|
function DayHeader({ label, total }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between',
|
||||||
|
padding: '10px 16px 4px',
|
||||||
|
fontSize: 11, letterSpacing: 0.6, textTransform: 'uppercase',
|
||||||
|
color: 'var(--ink-2)',
|
||||||
|
}}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span style={{ fontFamily: 'JetBrains Mono, monospace' }}>−{fmtNoCur(total)} ₽</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bottom nav ────────────────────────────────────────────────────
|
||||||
|
function BottomNav({ active = 0 }) {
|
||||||
|
const items = [
|
||||||
|
{ icon: Icons.home, label: 'Главная' },
|
||||||
|
{ icon: Icons.stats, label: 'Аналитика' },
|
||||||
|
{ icon: Icons.wallet, label: 'Счета' },
|
||||||
|
{ icon: Icons.user, label: 'Профиль' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', borderTop: '1px solid var(--line)',
|
||||||
|
background: 'var(--paper)',
|
||||||
|
}}>
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 3, padding: '8px 0 6px',
|
||||||
|
color: i === active ? 'var(--accent)' : 'var(--ink-2)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: i === active ? '2px 14px' : 0,
|
||||||
|
background: i === active ? 'var(--accent-soft)' : 'transparent',
|
||||||
|
borderRadius: 12, display: 'flex',
|
||||||
|
}}>{it.icon}</div>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: i === active ? 600 : 400 }}>{it.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FAB ───────────────────────────────────────────────────────────
|
||||||
|
function FAB({ bottom = 70, right = 16 }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom, right,
|
||||||
|
width: 52, height: 52, borderRadius: 16,
|
||||||
|
background: 'var(--accent)', color: 'var(--paper)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
|
||||||
|
}}>{Icons.plus}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wireframe app bar (compact, neutral) ──────────────────────────
|
||||||
|
function WfBar({ title, sub, right }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 16px 6px',
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{sub && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>
|
||||||
|
{sub}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', letterSpacing: -0.3 }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, color: 'var(--ink-2)' }}>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Variation label (printed below frame on canvas) ────────────────
|
||||||
|
function VLabel({ n, title, axes }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 412, padding: '14px 4px 0',
|
||||||
|
fontFamily: 'DM Sans, sans-serif',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 8, alignItems: 'baseline',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 11,
|
||||||
|
color: 'var(--ink-2)',
|
||||||
|
}}>0{n}</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>{title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{axes.map((a, i) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
fontSize: 10, padding: '2px 6px',
|
||||||
|
border: '1px dashed var(--line-2)', borderRadius: 6,
|
||||||
|
color: 'var(--ink-2)', letterSpacing: 0.2,
|
||||||
|
}}>{a}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
Ico, Icons, Note, NoteArrow,
|
||||||
|
fmt, fmtNoCur,
|
||||||
|
Donut, StackBar,
|
||||||
|
CATS, SPEND, SPEND_TOTAL, DONUT_DATA,
|
||||||
|
ACCOUNTS, TX,
|
||||||
|
TxRow, DayHeader, BottomNav, FAB, WfBar, VLabel,
|
||||||
|
});
|
||||||
@@ -0,0 +1,966 @@
|
|||||||
|
|
||||||
|
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||||
|
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||||
|
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||||
|
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||||
|
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||||
|
// via the host bridge. No assets, no deps.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <DesignCanvas>
|
||||||
|
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||||
|
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||||
|
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||||
|
// </DCSection>
|
||||||
|
// </DesignCanvas>
|
||||||
|
|
||||||
|
const DC = {
|
||||||
|
bg: '#f0eee9',
|
||||||
|
grid: 'rgba(0,0,0,0.06)',
|
||||||
|
label: 'rgba(60,50,40,0.7)',
|
||||||
|
title: 'rgba(40,30,20,0.85)',
|
||||||
|
subtitle: 'rgba(60,50,40,0.6)',
|
||||||
|
postitBg: '#fef4a8',
|
||||||
|
postitText: '#5a4a2a',
|
||||||
|
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||||
|
// the hosted design's own styles).
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.id = 'dc-styles';
|
||||||
|
s.textContent = [
|
||||||
|
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||||
|
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||||
|
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||||
|
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||||
|
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||||
|
// isolation:isolate contains artboard content's z-indexes so a
|
||||||
|
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
||||||
|
// the .dc-menu popover that drops into the top of the card.
|
||||||
|
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
||||||
|
'.dc-card *{scrollbar-width:none}',
|
||||||
|
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||||
|
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||||
|
// right. Single flex row; when the artboard's on-screen width is too
|
||||||
|
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||||
|
// ~4ch via the container query) and the buttons stay on the row.
|
||||||
|
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||||
|
' display:flex;align-items:center;container-type:inline-size}',
|
||||||
|
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||||
|
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||||
|
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||||
|
'.dc-grip:active{cursor:grabbing}',
|
||||||
|
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||||
|
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||||
|
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||||
|
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||||
|
// until the card is moused.
|
||||||
|
'@container (max-width: 110px){',
|
||||||
|
' .dc-labeltext{display:none}',
|
||||||
|
' .dc-grip{opacity:0}',
|
||||||
|
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||||
|
'}',
|
||||||
|
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||||
|
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||||
|
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||||
|
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||||
|
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
|
||||||
|
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||||
|
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
||||||
|
' font:inherit;transition:background .12s,color .12s}',
|
||||||
|
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||||
|
// Slot hosting an open menu floats above later siblings (which otherwise
|
||||||
|
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
||||||
|
// clipped by the next card.
|
||||||
|
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
||||||
|
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
|
||||||
|
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
|
||||||
|
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
|
||||||
|
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
|
||||||
|
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
||||||
|
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
|
||||||
|
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
||||||
|
'.dc-menu .dc-danger{color:#c96442}',
|
||||||
|
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
|
||||||
|
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||||
|
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||||
|
// DCViewport on every transform update and inherits to all descendants —
|
||||||
|
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||||
|
// it the same way.
|
||||||
|
//
|
||||||
|
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||||
|
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||||
|
// after counter-scaling its on-screen width exactly matches the card's —
|
||||||
|
// that's what lets the container query + text-overflow behave against the
|
||||||
|
// card's visible edge at every zoom level.
|
||||||
|
//
|
||||||
|
// The section head uses CSS zoom instead of transform so its layout box
|
||||||
|
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||||
|
// constant-screen-size title would overflow into the (shrinking) world-
|
||||||
|
// space gap and overlap the artboard headers at low zoom.
|
||||||
|
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||||
|
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||||
|
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
||||||
|
].join('\n');
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DCCtx = React.createContext(null);
|
||||||
|
|
||||||
|
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
|
||||||
|
// DCSection/DCArtboard children from the type-based walks below.
|
||||||
|
function dcFlatten(children) {
|
||||||
|
const out = [];
|
||||||
|
React.Children.forEach(children, (c) => {
|
||||||
|
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
|
||||||
|
else out.push(c);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||||
|
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||||
|
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
||||||
|
// .design-canvas.state.json
|
||||||
|
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||||
|
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||||
|
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||||
|
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||||
|
// Focus is ephemeral.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||||
|
|
||||||
|
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||||
|
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||||
|
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||||
|
// appear on first paint (no source-order flash). didRead gates writes until
|
||||||
|
// the read settles so the empty initial state can't clobber a slow read;
|
||||||
|
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||||
|
// hydration.
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
const didRead = React.useRef(false);
|
||||||
|
const skipNextWrite = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let off = false;
|
||||||
|
fetch('./' + DC_STATE_FILE)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((saved) => {
|
||||||
|
if (off || !saved || !saved.sections) return;
|
||||||
|
skipNextWrite.current = true;
|
||||||
|
setState((s) => ({ ...s, sections: saved.sections }));
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||||
|
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||||
|
return () => { off = true; clearTimeout(t); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!didRead.current) return;
|
||||||
|
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [state.sections]);
|
||||||
|
|
||||||
|
// Build registries synchronously from children so FocusOverlay can read
|
||||||
|
// them in the same render. Fragments are flattened; wrapping in other
|
||||||
|
// elements still opts out of focus/reorder.
|
||||||
|
const registry = {}; // slotId -> { sectionId, artboard }
|
||||||
|
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||||
|
const sectionOrder = [];
|
||||||
|
dcFlatten(children).forEach((sec) => {
|
||||||
|
if (!sec || sec.type !== DCSection) return;
|
||||||
|
const sid = sec.props.id ?? sec.props.title;
|
||||||
|
if (!sid) return;
|
||||||
|
sectionOrder.push(sid);
|
||||||
|
const persisted = state.sections[sid] || {};
|
||||||
|
const abs = [];
|
||||||
|
dcFlatten(sec.props.children).forEach((ab) => {
|
||||||
|
if (!ab || ab.type !== DCArtboard) return;
|
||||||
|
const aid = ab.props.id ?? ab.props.label;
|
||||||
|
if (aid) abs.push([aid, ab]);
|
||||||
|
});
|
||||||
|
// hidden is scoped to one source revision — when the agent regenerates
|
||||||
|
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||||
|
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||||
|
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||||
|
const srcIds = [];
|
||||||
|
abs.forEach(([aid, ab]) => {
|
||||||
|
if (hidden.includes(aid)) return;
|
||||||
|
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||||
|
srcIds.push(aid);
|
||||||
|
});
|
||||||
|
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||||
|
sectionMeta[sid] = {
|
||||||
|
title: persisted.title ?? sec.props.title,
|
||||||
|
subtitle: sec.props.subtitle,
|
||||||
|
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = React.useMemo(() => ({
|
||||||
|
state,
|
||||||
|
section: (id) => state.sections[id] || {},
|
||||||
|
patchSection: (id, p) => setState((s) => ({
|
||||||
|
...s,
|
||||||
|
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||||
|
})),
|
||||||
|
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||||
|
}), [state]);
|
||||||
|
|
||||||
|
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||||
|
const onPd = (e) => {
|
||||||
|
const ae = document.activeElement;
|
||||||
|
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.addEventListener('pointerdown', onPd, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.removeEventListener('pointerdown', onPd, true);
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DCCtx.Provider value={api}>
|
||||||
|
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||||
|
{state.focus && registry[state.focus] && (
|
||||||
|
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||||
|
)}
|
||||||
|
</DCCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DCViewport — transform-based pan/zoom (internal)
|
||||||
|
//
|
||||||
|
// Input mapping (Figma-style):
|
||||||
|
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||||
|
// • trackpad scroll → pan (two-finger)
|
||||||
|
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||||
|
// • middle-drag / primary-drag-on-bg → pan
|
||||||
|
//
|
||||||
|
// Transform state lives in a ref and is written straight to the DOM
|
||||||
|
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||||
|
// keeps pans at 60fps on dense canvases.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||||
|
const vpRef = React.useRef(null);
|
||||||
|
const worldRef = React.useRef(null);
|
||||||
|
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||||
|
// Persist viewport across reloads so the user lands back where they were
|
||||||
|
// after an agent edit or browser refresh. The sandbox origin is already
|
||||||
|
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||||
|
const tfKey = 'dc-viewport:' + location.pathname;
|
||||||
|
const saveT = React.useRef(0);
|
||||||
|
|
||||||
|
const lastPostedScale = React.useRef();
|
||||||
|
const apply = React.useCallback(() => {
|
||||||
|
const { x, y, scale } = tf.current;
|
||||||
|
const el = worldRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||||
|
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||||
|
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||||
|
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||||
|
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||||
|
if (lastPostedScale.current !== scale) {
|
||||||
|
lastPostedScale.current = scale;
|
||||||
|
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||||
|
}
|
||||||
|
clearTimeout(saveT.current);
|
||||||
|
saveT.current = setTimeout(() => {
|
||||||
|
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||||
|
}, 200);
|
||||||
|
}, [tfKey]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const flush = () => {
|
||||||
|
clearTimeout(saveT.current);
|
||||||
|
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||||
|
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||||
|
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||||
|
// window doesn't drop the last pan/zoom.
|
||||||
|
window.addEventListener('pagehide', flush);
|
||||||
|
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const vp = vpRef.current;
|
||||||
|
if (!vp) return;
|
||||||
|
|
||||||
|
const zoomAt = (cx, cy, factor) => {
|
||||||
|
const r = vp.getBoundingClientRect();
|
||||||
|
const px = cx - r.left, py = cy - r.top;
|
||||||
|
const t = tf.current;
|
||||||
|
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||||
|
const k = next / t.scale;
|
||||||
|
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
|
||||||
|
// marginBottom) reflow on every scale change, vertically shifting the
|
||||||
|
// world layout — so a world point mathematically pinned under the cursor
|
||||||
|
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
|
||||||
|
// Anchor the DOM element under the cursor instead: record its screen Y,
|
||||||
|
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
|
||||||
|
// drift the reflow introduced so it stays put on screen.
|
||||||
|
let marker = null, markerY0 = 0;
|
||||||
|
if (k !== 1) {
|
||||||
|
const hit = document.elementFromPoint(cx, cy);
|
||||||
|
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
|
||||||
|
if (marker) markerY0 = marker.getBoundingClientRect().top;
|
||||||
|
}
|
||||||
|
// keep the world point under the cursor fixed
|
||||||
|
t.x = px - (px - t.x) * k;
|
||||||
|
t.y = py - (py - t.y) * k;
|
||||||
|
t.scale = next;
|
||||||
|
apply();
|
||||||
|
if (marker) {
|
||||||
|
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
|
||||||
|
// departure after the --dc-inv-zoom reflow is the layout drift.
|
||||||
|
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
|
||||||
|
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||||
|
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||||
|
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||||
|
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||||
|
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||||
|
const isMouseWheel = (e) =>
|
||||||
|
e.deltaMode !== 0 ||
|
||||||
|
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||||
|
|
||||||
|
const onWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
||||||
|
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
||||||
|
// wheels fall through to the fixed-step branch below.
|
||||||
|
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||||
|
} else if (isMouseWheel(e)) {
|
||||||
|
// notched mouse wheel — fixed-ratio step per click
|
||||||
|
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||||
|
} else {
|
||||||
|
// trackpad two-finger scroll — pan
|
||||||
|
tf.current.x -= e.deltaX;
|
||||||
|
tf.current.y -= e.deltaY;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||||
|
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||||
|
// better feel there. No-ops on other browsers. Safari also fires
|
||||||
|
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||||
|
// onWheel drop those entirely so they neither zoom nor pan.
|
||||||
|
let gsBase = 1;
|
||||||
|
let isGesturing = false;
|
||||||
|
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||||
|
const onGestureChange = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||||
|
};
|
||||||
|
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||||
|
|
||||||
|
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||||
|
// background (anything that isn't an artboard or an inline editor).
|
||||||
|
let drag = null;
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||||
|
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||||
|
e.preventDefault();
|
||||||
|
vp.setPointerCapture(e.pointerId);
|
||||||
|
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||||
|
vp.style.cursor = 'grabbing';
|
||||||
|
};
|
||||||
|
const onPointerMove = (e) => {
|
||||||
|
if (!drag || e.pointerId !== drag.id) return;
|
||||||
|
tf.current.x += e.clientX - drag.lx;
|
||||||
|
tf.current.y += e.clientY - drag.ly;
|
||||||
|
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
const onPointerUp = (e) => {
|
||||||
|
if (!drag || e.pointerId !== drag.id) return;
|
||||||
|
vp.releasePointerCapture(e.pointerId);
|
||||||
|
drag = null;
|
||||||
|
vp.style.cursor = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||||
|
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||||
|
const onHostMsg = (e) => {
|
||||||
|
const d = e.data;
|
||||||
|
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||||
|
const r = vp.getBoundingClientRect();
|
||||||
|
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||||
|
} else if (d && d.type === '__dc_probe') {
|
||||||
|
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||||
|
// fires on the iframe's native 'load', which for canvases with
|
||||||
|
// images/fonts is after our mount-time announce, so re-announce.
|
||||||
|
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||||
|
// even if it's unchanged — the host just reset dcScale to 1.
|
||||||
|
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||||
|
lastPostedScale.current = undefined;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onHostMsg);
|
||||||
|
// Announce canvas mode so the host toolbar proxies its % control here
|
||||||
|
// instead of scaling the iframe element (which would just shrink the
|
||||||
|
// viewport window of an infinite canvas). The apply() that follows emits
|
||||||
|
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||||
|
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||||
|
// effect's restore-path apply() may already have posted the restored
|
||||||
|
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||||
|
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||||
|
lastPostedScale.current = undefined;
|
||||||
|
apply();
|
||||||
|
|
||||||
|
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||||
|
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||||
|
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||||
|
vp.addEventListener('pointerdown', onPointerDown);
|
||||||
|
vp.addEventListener('pointermove', onPointerMove);
|
||||||
|
vp.addEventListener('pointerup', onPointerUp);
|
||||||
|
vp.addEventListener('pointercancel', onPointerUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', onHostMsg);
|
||||||
|
vp.removeEventListener('wheel', onWheel);
|
||||||
|
vp.removeEventListener('gesturestart', onGestureStart);
|
||||||
|
vp.removeEventListener('gesturechange', onGestureChange);
|
||||||
|
vp.removeEventListener('gestureend', onGestureEnd);
|
||||||
|
vp.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
vp.removeEventListener('pointermove', onPointerMove);
|
||||||
|
vp.removeEventListener('pointerup', onPointerUp);
|
||||||
|
vp.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
};
|
||||||
|
}, [apply, minScale, maxScale]);
|
||||||
|
|
||||||
|
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={vpRef}
|
||||||
|
className="design-canvas"
|
||||||
|
style={{
|
||||||
|
height: '100vh', width: '100vw',
|
||||||
|
background: DC.bg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
overscrollBehavior: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: DC.font,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={worldRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, left: 0,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
willChange: 'transform',
|
||||||
|
width: 'max-content', minWidth: '100%',
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: '60px 0 80px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DCSection — editable title + h-row of artboards in persisted order
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||||
|
const ctx = React.useContext(DCCtx);
|
||||||
|
const sid = id ?? title;
|
||||||
|
const all = React.Children.toArray(dcFlatten(children));
|
||||||
|
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||||
|
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||||
|
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||||
|
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||||
|
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||||
|
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||||
|
const srcKey = allIds.join('\x1f');
|
||||||
|
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||||
|
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||||
|
|
||||||
|
const order = React.useMemo(() => {
|
||||||
|
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||||
|
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||||
|
}, [sec.order, srcOrder.join('|')]);
|
||||||
|
|
||||||
|
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||||
|
|
||||||
|
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||||
|
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||||
|
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||||
|
// belonging to the section above. paddingBottom below is just enough for
|
||||||
|
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||||
|
// the title sits tight against its own row at every zoom.
|
||||||
|
return (
|
||||||
|
<div data-dc-section={sid}
|
||||||
|
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||||
|
<div style={{ padding: '0 60px' }}>
|
||||||
|
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||||
|
<DCEditable tag="div" value={sec.title ?? title}
|
||||||
|
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||||
|
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||||
|
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||||
|
{order.map((k) => (
|
||||||
|
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||||
|
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||||
|
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||||
|
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||||
|
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||||
|
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||||
|
srcKey,
|
||||||
|
}))}
|
||||||
|
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rest}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||||
|
function DCArtboard() { return null; }
|
||||||
|
|
||||||
|
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
||||||
|
// self-contained clone: computed styles baked in, @font-face / <img> /
|
||||||
|
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
||||||
|
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
||||||
|
// (same pipeline the host uses for page captures); HTML wraps it in a
|
||||||
|
// minimal standalone document. Both are independent of viewport zoom.
|
||||||
|
async function dcExport(node, w, h, name, kind) {
|
||||||
|
try { await document.fonts.ready; } catch {}
|
||||||
|
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
||||||
|
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
||||||
|
})).catch(() => url);
|
||||||
|
|
||||||
|
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
||||||
|
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
||||||
|
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
||||||
|
// the blocks. @import and @media/@supports are walked so nested
|
||||||
|
// @font-face rules aren't missed.
|
||||||
|
const fontRules = [], pending = [], seen = new Set();
|
||||||
|
const scrapeCss = (href) => {
|
||||||
|
if (seen.has(href)) return; seen.add(href);
|
||||||
|
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
||||||
|
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
||||||
|
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
||||||
|
scrapeCss(new URL(m[1], href).href);
|
||||||
|
}).catch(() => {}));
|
||||||
|
};
|
||||||
|
const walk = (rules, base) => {
|
||||||
|
for (const r of rules) {
|
||||||
|
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
||||||
|
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
||||||
|
const ibase = r.styleSheet.href || base;
|
||||||
|
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
||||||
|
} else if (r.cssRules) walk(r.cssRules, base);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const ss of document.styleSheets) {
|
||||||
|
const base = ss.href || location.href;
|
||||||
|
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
||||||
|
}
|
||||||
|
while (pending.length) await pending.shift();
|
||||||
|
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
||||||
|
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
||||||
|
while ((m = re.exec(rule.css))) {
|
||||||
|
if (m[2].indexOf('data:') === 0) continue;
|
||||||
|
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
||||||
|
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}))).join('\n');
|
||||||
|
|
||||||
|
const cloneStyled = (src) => {
|
||||||
|
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
||||||
|
const dst = src.cloneNode(false);
|
||||||
|
if (src.nodeType === 1) {
|
||||||
|
const cs = getComputedStyle(src); let txt = '';
|
||||||
|
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
||||||
|
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
||||||
|
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
||||||
|
}
|
||||||
|
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
||||||
|
return dst;
|
||||||
|
};
|
||||||
|
const clone = cloneStyled(node);
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||||
|
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
||||||
|
// the artboard's own background (if any) is already in the computed style.
|
||||||
|
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
||||||
|
|
||||||
|
const jobs = [];
|
||||||
|
clone.querySelectorAll('img').forEach((el) => {
|
||||||
|
const s = el.getAttribute('src');
|
||||||
|
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
||||||
|
});
|
||||||
|
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
||||||
|
const bg = el.style.backgroundImage; if (!bg) return;
|
||||||
|
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
||||||
|
while ((m = re.exec(bg))) {
|
||||||
|
const tok = m[0], url = m[1];
|
||||||
|
if (url.indexOf('data:') === 0) continue;
|
||||||
|
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(jobs);
|
||||||
|
|
||||||
|
const xml = new XMLSerializer().serializeToString(clone);
|
||||||
|
const save = (blob, ext) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind === 'html') {
|
||||||
|
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||||
|
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||||
|
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||||
|
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG: the SVG's own width/height must be the output resolution — an
|
||||||
|
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
||||||
|
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
||||||
|
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
||||||
|
// the HTML at full resolution.
|
||||||
|
const px = 3;
|
||||||
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||||
|
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||||
|
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
||||||
|
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||||
|
});
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.width = w * px; cv.height = h * px;
|
||||||
|
cv.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
||||||
|
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||||
|
const id = rawId ?? rawLabel;
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
const cardRef = React.useRef(null);
|
||||||
|
const menuRef = React.useRef(null);
|
||||||
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
|
const [confirming, setConfirming] = React.useState(false);
|
||||||
|
|
||||||
|
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
||||||
|
// the menu — first click arms the row, second commits; closing disarms.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!menuOpen) { setConfirming(false); return; }
|
||||||
|
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||||
|
document.addEventListener('pointerdown', off, true);
|
||||||
|
return () => document.removeEventListener('pointerdown', off, true);
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const doExport = (kind) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (!cardRef.current) return;
|
||||||
|
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
||||||
|
dcExport(cardRef.current, width, height, name, kind)
|
||||||
|
.catch((e) => console.error('[design-canvas] export failed:', e));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||||
|
// their would-be slots in real time via transforms. DOM order only
|
||||||
|
// changes on drop.
|
||||||
|
const onGripDown = (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const me = ref.current;
|
||||||
|
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||||
|
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||||
|
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||||
|
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||||
|
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||||
|
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||||
|
const slotXs = homes.map((h) => h.x);
|
||||||
|
const startIdx = order.indexOf(id);
|
||||||
|
const startX = e.clientX;
|
||||||
|
let liveOrder = order.slice();
|
||||||
|
me.classList.add('dc-dragging');
|
||||||
|
|
||||||
|
const layout = () => {
|
||||||
|
for (const h of homes) {
|
||||||
|
if (h.id === id) continue;
|
||||||
|
const slot = liveOrder.indexOf(h.id);
|
||||||
|
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startX;
|
||||||
|
me.style.transform = `translateX(${dx / scale}px)`;
|
||||||
|
const cur = homes[startIdx].x + dx;
|
||||||
|
let nearest = 0, best = Infinity;
|
||||||
|
for (let i = 0; i < slotXs.length; i++) {
|
||||||
|
const d = Math.abs(slotXs[i] - cur);
|
||||||
|
if (d < best) { best = d; nearest = i; }
|
||||||
|
}
|
||||||
|
if (liveOrder.indexOf(id) !== nearest) {
|
||||||
|
liveOrder = order.filter((k) => k !== id);
|
||||||
|
liveOrder.splice(nearest, 0, id);
|
||||||
|
layout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const up = () => {
|
||||||
|
document.removeEventListener('pointermove', move);
|
||||||
|
document.removeEventListener('pointerup', up);
|
||||||
|
const finalSlot = liveOrder.indexOf(id);
|
||||||
|
me.classList.remove('dc-dragging');
|
||||||
|
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||||
|
// After the settle transition, kill transitions + clear transforms +
|
||||||
|
// commit the reorder in the same frame so there's no visual snap-back.
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||||
|
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
for (const h of homes) h.el.style.transition = '';
|
||||||
|
}));
|
||||||
|
}, 180);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointermove', move);
|
||||||
|
document.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<div className="dc-header" data-omelette-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dc-labelrow">
|
||||||
|
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||||
|
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||||
|
</div>
|
||||||
|
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||||
|
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dc-btns">
|
||||||
|
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||||
|
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||||
|
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||||
|
<hr />
|
||||||
|
<button className="dc-danger"
|
||||||
|
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||||
|
{confirming ? 'Click again to delete' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={cardRef} className="dc-card"
|
||||||
|
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||||
|
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline rename — commits on blur or Enter.
|
||||||
|
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||||
|
const T = tag;
|
||||||
|
return (
|
||||||
|
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||||
|
onClick={onClick}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||||
|
style={style}>{value}</T>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||||
|
// sections, Esc or backdrop click to exit.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||||
|
const ctx = React.useContext(DCCtx);
|
||||||
|
const { sectionId, artboard } = entry;
|
||||||
|
const sec = ctx.section(sectionId);
|
||||||
|
const meta = sectionMeta[sectionId];
|
||||||
|
const peers = meta.slotIds;
|
||||||
|
const aid = artboard.props.id ?? artboard.props.label;
|
||||||
|
const idx = peers.indexOf(aid);
|
||||||
|
const secIdx = sectionOrder.indexOf(sectionId);
|
||||||
|
|
||||||
|
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||||
|
const goSection = (d) => {
|
||||||
|
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||||
|
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||||
|
const n = sectionOrder.length;
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||||
|
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||||
|
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const k = (e) => {
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', k);
|
||||||
|
return () => document.removeEventListener('keydown', k);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { width = 260, height = 480, children } = artboard.props;
|
||||||
|
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||||
|
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||||
|
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||||
|
|
||||||
|
const [ddOpen, setDd] = React.useState(false);
|
||||||
|
const Arrow = ({ dir, onClick }) => (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||||
|
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||||
|
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||||
|
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Portal to body so position:fixed is the real viewport regardless of any
|
||||||
|
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div onClick={() => ctx.setFocus(null)}
|
||||||
|
onWheel={(e) => e.preventDefault()}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||||
|
fontFamily: DC.font, color: '#fff' }}>
|
||||||
|
|
||||||
|
{/* top bar: section dropdown (left) · close (right) */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button onClick={() => setDd((o) => !o)}
|
||||||
|
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||||
|
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||||
|
</span>
|
||||||
|
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||||
|
</button>
|
||||||
|
{ddOpen && (
|
||||||
|
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||||
|
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||||
|
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||||
|
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||||
|
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||||
|
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||||
|
{sectionMeta[sid].title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button onClick={() => ctx.setFocus(null)}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||||
|
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* card centered, label + index below — only the card itself stops
|
||||||
|
propagation so any backdrop click (including the margins around
|
||||||
|
the card) exits focus */}
|
||||||
|
<div
|
||||||
|
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||||
|
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||||
|
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||||
|
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||||
|
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||||
|
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Arrow dir="left" onClick={() => go(-1)} />
|
||||||
|
<Arrow dir="right" onClick={() => go(1)} />
|
||||||
|
|
||||||
|
{/* dots */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||||
|
{peers.map((p, i) => (
|
||||||
|
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||||
|
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||||
|
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Post-it — absolute-positioned sticky note
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top, left, right, bottom, width,
|
||||||
|
background: DC.postitBg, padding: '14px 16px',
|
||||||
|
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||||
|
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
transform: `rotate(${rotate}deg)`,
|
||||||
|
zIndex: 5,
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||||
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Главный экран — Бюджет · вайрфреймы</title>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Caveat:wght@500;600&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
/* Light tokens */
|
||||||
|
--paper: #f6f4ef;
|
||||||
|
--paper-2: #efece5;
|
||||||
|
--card-soft: #edeae3;
|
||||||
|
--ink: #1c1c1a;
|
||||||
|
--ink-2: #6b6b66;
|
||||||
|
--line: #d8d5cc;
|
||||||
|
--line-2: #b8b5ac;
|
||||||
|
--accent: #4a8a82;
|
||||||
|
--accent-soft:#dde9e6;
|
||||||
|
--pos: #6f8c69;
|
||||||
|
--neg: #b3675a;
|
||||||
|
--canvas-bg: #efece5;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--paper: #19191a;
|
||||||
|
--paper-2: #232325;
|
||||||
|
--card-soft: #232325;
|
||||||
|
--ink: #ece9e2;
|
||||||
|
--ink-2: #8d8a83;
|
||||||
|
--line: #2e2d2a;
|
||||||
|
--line-2: #4a4845;
|
||||||
|
--accent: #76b3a9;
|
||||||
|
--accent-soft:#23332f;
|
||||||
|
--pos: #92b58a;
|
||||||
|
--neg: #d18d7e;
|
||||||
|
--canvas-bg: #111112;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { margin: 0; padding: 0; background: var(--canvas-bg); }
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Wireframe root sets dark/light tokens for all frames */
|
||||||
|
.wf-root { color: var(--ink); }
|
||||||
|
|
||||||
|
/* Remove webkit scrollbars on hscrolls */
|
||||||
|
.wf-root *::-webkit-scrollbar { display: none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- React + Babel -->
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||||
|
<script type="text/babel" src="android-frame.jsx"></script>
|
||||||
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
|
<script type="text/babel" src="common.jsx"></script>
|
||||||
|
<script type="text/babel" src="variants.jsx"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
"theme": "dark"
|
||||||
|
}/*EDITMODE-END*/;
|
||||||
|
|
||||||
|
// Custom Android frame that uses our CSS-var palette (so dark/light flows through)
|
||||||
|
function PhoneShell({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 412, height: 892, borderRadius: 18, overflow: 'hidden',
|
||||||
|
background: 'var(--paper)',
|
||||||
|
border: '8px solid var(--line-2)',
|
||||||
|
boxShadow: '0 24px 60px rgba(0,0,0,0.18)',
|
||||||
|
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
|
||||||
|
}}>
|
||||||
|
{/* Status bar */}
|
||||||
|
<div style={{
|
||||||
|
height: 32, padding: '0 16px', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
position: 'relative', fontSize: 12, color: 'var(--ink)',
|
||||||
|
fontFamily: 'DM Sans, sans-serif',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 500 }}>9:30</span>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: '50%', top: 6, transform: 'translateX(-50%)',
|
||||||
|
width: 18, height: 18, borderRadius: '50%', background: 'var(--ink)',
|
||||||
|
opacity: 0.85,
|
||||||
|
}} />
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center', opacity: 0.85 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill="currentColor"/></svg>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill="currentColor"/></svg>
|
||||||
|
<svg width="16" height="14" viewBox="0 0 16 16"><rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill="currentColor"/><rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill="currentColor"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', position: 'relative' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom nav */}
|
||||||
|
<BottomNav active={0} />
|
||||||
|
|
||||||
|
{/* Gesture pill */}
|
||||||
|
<div style={{ height: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--paper)' }}>
|
||||||
|
<div style={{ width: 96, height: 4, borderRadius: 2, background: 'var(--ink)', opacity: 0.35 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variation meta
|
||||||
|
const VARIANTS = [
|
||||||
|
{ n: 1, title: 'Segmented account tabs',
|
||||||
|
Component: V1,
|
||||||
|
axes: ['Счёт: горизонтальные таб-пиллы', 'KPI: баланс + доходырасходы', 'Фильтр: bottom-sheet триггер', 'Список: группировка по дням'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Stage() {
|
||||||
|
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', tweaks.theme);
|
||||||
|
}, [tweaks.theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wf-root" data-screen-label="Главный экран">
|
||||||
|
<DesignCanvas>
|
||||||
|
<DCSection id="phones" title="Главный экран">
|
||||||
|
{VARIANTS.map(v => (
|
||||||
|
<DCArtboard key={v.n}
|
||||||
|
id={`v${v.n}`}
|
||||||
|
label={`0${v.n} · ${v.title}`}
|
||||||
|
width={412} height={892}>
|
||||||
|
<PhoneShell><v.Component /></PhoneShell>
|
||||||
|
</DCArtboard>
|
||||||
|
))}
|
||||||
|
</DCSection>
|
||||||
|
</DesignCanvas>
|
||||||
|
|
||||||
|
<TweaksPanel>
|
||||||
|
<TweakSection label="Тема">
|
||||||
|
<TweakRadio
|
||||||
|
label="Режим"
|
||||||
|
value={tweaks.theme}
|
||||||
|
onChange={v => setTweak('theme', v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'light', label: 'Светлая' },
|
||||||
|
{ value: 'dark', label: 'Тёмная' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TweakSection>
|
||||||
|
</TweaksPanel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<Stage />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
|
||||||
|
// tweaks-panel.jsx
|
||||||
|
// Reusable Tweaks shell + form-control helpers.
|
||||||
|
//
|
||||||
|
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||||
|
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||||
|
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||||
|
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||||
|
//
|
||||||
|
// Usage (in an HTML file that loads React + Babel):
|
||||||
|
//
|
||||||
|
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
// "primaryColor": "#D97757",
|
||||||
|
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
||||||
|
// "fontSize": 16,
|
||||||
|
// "density": "regular",
|
||||||
|
// "dark": false
|
||||||
|
// }/*EDITMODE-END*/;
|
||||||
|
//
|
||||||
|
// function App() {
|
||||||
|
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
// return (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakColor label="Palette" value={t.palette}
|
||||||
|
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||||
|
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||||
|
// onChange={(v) => setTweak('palette', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const __TWEAKS_STYLE = `
|
||||||
|
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||||
|
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||||
|
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||||||
|
background:rgba(250,249,247,.78);color:#29261b;
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||||
|
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||||
|
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||||
|
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||||
|
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||||
|
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||||
|
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||||
|
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||||
|
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||||
|
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.twk-body::-webkit-scrollbar{width:8px}
|
||||||
|
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||||
|
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
color:rgba(41,38,27,.72)}
|
||||||
|
.twk-lbl>span:first-child{font-weight:500}
|
||||||
|
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||||
|
.twk-sect:first-child{padding-top:0}
|
||||||
|
|
||||||
|
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||||
|
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||||
|
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||||
|
select.twk-field{padding-right:22px;
|
||||||
|
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
background-repeat:no-repeat;background-position:right 8px center}
|
||||||
|
|
||||||
|
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||||
|
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||||
|
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||||
|
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||||
|
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
|
||||||
|
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||||
|
background:rgba(0,0,0,.06);user-select:none}
|
||||||
|
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||||
|
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||||
|
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||||
|
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||||
|
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||||
|
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||||
|
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||||
|
overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||||
|
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||||
|
.twk-toggle[data-on="1"]{background:#34c759}
|
||||||
|
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||||
|
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||||
|
|
||||||
|
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||||
|
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||||
|
user-select:none;padding-right:8px}
|
||||||
|
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||||
|
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||||
|
outline:none;color:inherit;-moz-appearance:textfield}
|
||||||
|
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||||
|
-webkit-appearance:none;margin:0}
|
||||||
|
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||||
|
|
||||||
|
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||||
|
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||||
|
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||||
|
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||||
|
|
||||||
|
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||||
|
background:transparent;flex-shrink:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
|
||||||
|
.twk-chips{display:flex;gap:6px}
|
||||||
|
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
||||||
|
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||||||
|
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||||||
|
.twk-chip:hover{transform:translateY(-1px);
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||||||
|
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
||||||
|
0 2px 6px rgba(0,0,0,.15)}
|
||||||
|
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||||||
|
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i:first-child{box-shadow:none}
|
||||||
|
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
||||||
|
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||||
|
// Single source of truth for tweak values. setTweak persists via the host
|
||||||
|
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||||
|
function useTweaks(defaults) {
|
||||||
|
const [values, setValues] = React.useState(defaults);
|
||||||
|
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||||
|
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||||
|
// JSON block.
|
||||||
|
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||||
|
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||||
|
? keyOrEdits : { [keyOrEdits]: val };
|
||||||
|
setValues((prev) => ({ ...prev, ...edits }));
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||||
|
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
||||||
|
// can react — the parent message only reaches the host, not peers.
|
||||||
|
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||||||
|
}, []);
|
||||||
|
return [values, setTweak];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||||
|
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||||
|
// availability — if the announce ran first, the host's activate could land
|
||||||
|
// before our handler exists and the toolbar toggle would silently no-op.
|
||||||
|
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||||
|
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||||
|
// is what actually hides the panel.
|
||||||
|
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dragRef = React.useRef(null);
|
||||||
|
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||||
|
const PAD = 16;
|
||||||
|
|
||||||
|
const clampToViewport = React.useCallback(() => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||||
|
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||||
|
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||||
|
offsetRef.current = {
|
||||||
|
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||||
|
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||||
|
};
|
||||||
|
panel.style.right = offsetRef.current.x + 'px';
|
||||||
|
panel.style.bottom = offsetRef.current.y + 'px';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
clampToViewport();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', clampToViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampToViewport);
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(clampToViewport);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open, clampToViewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const t = e?.data?.type;
|
||||||
|
if (t === '__activate_edit_mode') setOpen(true);
|
||||||
|
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e) => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const r = panel.getBoundingClientRect();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const startRight = window.innerWidth - r.right;
|
||||||
|
const startBottom = window.innerHeight - r.bottom;
|
||||||
|
const move = (ev) => {
|
||||||
|
offsetRef.current = {
|
||||||
|
x: startRight - (ev.clientX - sx),
|
||||||
|
y: startBottom - (ev.clientY - sy),
|
||||||
|
};
|
||||||
|
clampToViewport();
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRadio({ label, value, options, onChange }) {
|
||||||
|
const trackRef = React.useRef(null);
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
// The active value is read by pointer-move handlers attached for the lifetime
|
||||||
|
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||||
|
const valueRef = React.useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
// Segments wrap mid-word once per-segment width runs out. The track is
|
||||||
|
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
||||||
|
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
||||||
|
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
||||||
|
// back to a dropdown rather than wrap.
|
||||||
|
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
||||||
|
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||||||
|
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||||||
|
if (!fitsAsSegments) {
|
||||||
|
// <select> emits strings — map back to the original option value so the
|
||||||
|
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||||
|
const resolve = (s) => {
|
||||||
|
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||||
|
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||||
|
};
|
||||||
|
return <TweakSelect label={label} value={value} options={options}
|
||||||
|
onChange={(s) => onChange(resolve(s))} />;
|
||||||
|
}
|
||||||
|
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||||
|
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||||
|
const n = opts.length;
|
||||||
|
|
||||||
|
const segAt = (clientX) => {
|
||||||
|
const r = trackRef.current.getBoundingClientRect();
|
||||||
|
const inner = r.width - 4;
|
||||||
|
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||||
|
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
const v0 = segAt(e.clientX);
|
||||||
|
if (v0 !== valueRef.current) onChange(v0);
|
||||||
|
const move = (ev) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const v = segAt(ev.clientX);
|
||||||
|
if (v !== valueRef.current) onChange(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||||
|
const clamp = (n) => {
|
||||||
|
if (min != null && n < min) return min;
|
||||||
|
if (max != null && n > max) return max;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startRef = React.useRef({ x: 0, val: 0 });
|
||||||
|
const onScrubStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRef.current = { x: e.clientX, val: value };
|
||||||
|
const decimals = (String(step).split('.')[1] || '').length;
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startRef.current.x;
|
||||||
|
const raw = startRef.current.val + dx * step;
|
||||||
|
const snapped = Math.round(raw / step) * step;
|
||||||
|
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
||||||
|
// read on both #111 and #fafafa without per-option configuration. Hex input
|
||||||
|
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
||||||
|
function __twkIsLight(hex) {
|
||||||
|
const h = String(hex).replace('#', '');
|
||||||
|
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
||||||
|
const n = parseInt(x.slice(0, 6), 16);
|
||||||
|
if (Number.isNaN(n)) return true;
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
return r * 299 + g * 587 + b * 114 > 148000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __TwkCheck = ({ light }) => (
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// TweakColor — curated color/palette picker. Each option is either a single
|
||||||
|
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
||||||
|
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
||||||
|
// rest stacked in a sharp column on the right. onChange emits the
|
||||||
|
// option in the shape it was passed (string stays string, array stays array).
|
||||||
|
// Without options it falls back to the native color input for back-compat.
|
||||||
|
function TweakColor({ label, value, options, onChange }) {
|
||||||
|
if (!options || !options.length) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
||||||
|
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
||||||
|
// which returns the primitive undefined (no .toLowerCase).
|
||||||
|
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
||||||
|
const cur = key(value);
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div className="twk-chips" role="radiogroup">
|
||||||
|
{options.map((o, i) => {
|
||||||
|
const colors = Array.isArray(o) ? o : [o];
|
||||||
|
const [hero, ...rest] = colors;
|
||||||
|
const sup = rest.slice(0, 4);
|
||||||
|
const on = key(o) === cur;
|
||||||
|
return (
|
||||||
|
<button key={i} type="button" className="twk-chip" role="radio"
|
||||||
|
aria-checked={on} data-on={on ? '1' : '0'}
|
||||||
|
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||||
|
style={{ background: hero }}
|
||||||
|
onClick={() => onChange(o)}>
|
||||||
|
{sup.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
// 5 home-screen wireframe variations.
|
||||||
|
// Each is a function returning the inner-content of an AndroidDevice (status bar + nav are added by the frame).
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Shared sub-blocks
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
function MonthChip({ label = 'Май 2026', tight }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: tight ? '4px 8px' : '6px 10px',
|
||||||
|
border: '1px solid var(--line)', borderRadius: 999,
|
||||||
|
fontSize: 12, color: 'var(--ink)', background: 'var(--paper)',
|
||||||
|
}}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KPI({ label, value, hint, accent, mono = true }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, color: 'var(--ink-2)',
|
||||||
|
letterSpacing: 0.6, textTransform: 'uppercase',
|
||||||
|
}}>{label}</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 17, fontWeight: 600, color: accent || 'var(--ink)',
|
||||||
|
fontFamily: mono ? 'JetBrains Mono, monospace' : undefined,
|
||||||
|
fontVariantNumeric: 'tabular-nums', letterSpacing: -0.3,
|
||||||
|
}}>{value}</div>
|
||||||
|
{hint && (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-2)' }}>{hint}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryChip({ cat, active, count, onClick, dense }) {
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: dense ? '4px 9px' : '6px 11px',
|
||||||
|
borderRadius: 999, flexShrink: 0,
|
||||||
|
border: '1px solid ' + (active ? 'var(--ink)' : 'var(--line)'),
|
||||||
|
background: active ? 'var(--ink)' : 'var(--paper)',
|
||||||
|
color: active ? 'var(--paper)' : 'var(--ink)',
|
||||||
|
fontSize: 12, fontWeight: active ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{cat?.color && (
|
||||||
|
<div style={{
|
||||||
|
width: 8, height: 8, borderRadius: 99,
|
||||||
|
background: active ? 'var(--paper)' : cat.color,
|
||||||
|
}}/>
|
||||||
|
)}
|
||||||
|
<span>{cat ? cat.label : 'Все'}</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span style={{ fontSize: 10, opacity: 0.7 }}>· {count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal scroll strip
|
||||||
|
function HScroll({ children, gap = 8, pad = 16 }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap, padding: `0 ${pad}px`,
|
||||||
|
overflowX: 'auto', scrollbarWidth: 'none',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// V1 — Tab pills + KPI strip + donut + bottomsheet-trigger filter
|
||||||
|
// + day-grouped transactions
|
||||||
|
// =====================================================================
|
||||||
|
function V1() {
|
||||||
|
const [acc, setAcc] = React.useState('all');
|
||||||
|
const [filterCat, setFilterCat] = React.useState(null);
|
||||||
|
const [active, setActive] = React.useState(null);
|
||||||
|
const visibleTx = filterCat ? TX.filter(t => t.cat === filterCat) : TX;
|
||||||
|
|
||||||
|
// Group transactions by `when` field (we'll use first comma-split as day key).
|
||||||
|
const groups = React.useMemo(() => {
|
||||||
|
const byDay = new Map();
|
||||||
|
for (const t of visibleTx) {
|
||||||
|
const day = t.when.split(',')[0].trim();
|
||||||
|
if (!byDay.has(day)) byDay.set(day, []);
|
||||||
|
byDay.get(day).push(t);
|
||||||
|
}
|
||||||
|
return Array.from(byDay, ([day, items]) => ({
|
||||||
|
day,
|
||||||
|
items,
|
||||||
|
total: items.reduce((s, t) => s + (t.amount < 0 ? -t.amount : 0), 0),
|
||||||
|
}));
|
||||||
|
}, [visibleTx]);
|
||||||
|
|
||||||
|
const activeCat = filterCat && CATS.find(c => c.id === filterCat);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '14px 16px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>Бюджет</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink)' }}>Май 2026</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, color: 'var(--ink-2)' }}>
|
||||||
|
{Icons.search}{Icons.bell}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account tabs (segmented pills, scrollable) */}
|
||||||
|
<HScroll>
|
||||||
|
{ACCOUNTS.map(a => (
|
||||||
|
<div key={a.id} onClick={() => setAcc(a.id)} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '7px 12px', borderRadius: 999, flexShrink: 0,
|
||||||
|
border: '1px solid ' + (acc === a.id ? 'var(--ink)' : 'var(--line)'),
|
||||||
|
background: acc === a.id ? 'var(--ink)' : 'var(--paper)',
|
||||||
|
color: acc === a.id ? 'var(--paper)' : 'var(--ink)',
|
||||||
|
fontSize: 13, fontWeight: acc === a.id ? 600 : 400,
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', opacity: acc === a.id ? 1 : 0.7 }}>{a.icon}</span>
|
||||||
|
<span>{a.short}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</HScroll>
|
||||||
|
|
||||||
|
{/* Month KPI strip — balance + доходы / расходы only */}
|
||||||
|
<div style={{
|
||||||
|
margin: '12px 16px 14px',
|
||||||
|
border: '1px solid var(--line)', borderRadius: 14,
|
||||||
|
padding: '14px 14px 14px', background: 'var(--paper)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Баланс</span>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 22, fontWeight: 600,
|
||||||
|
color: 'var(--ink)', letterSpacing: -0.4,
|
||||||
|
}}>184 320 ₽</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1px 1fr', gap: 12, alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 2 }}>Доходы</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 18, fontWeight: 600,
|
||||||
|
color: 'var(--pos)', letterSpacing: -0.3,
|
||||||
|
}}>+95 000 ₽</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 2 }}>Расходы</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 18, fontWeight: 600,
|
||||||
|
color: 'var(--neg)', letterSpacing: -0.3,
|
||||||
|
}}>−67 200 ₽</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donut + legend */}
|
||||||
|
<div style={{
|
||||||
|
margin: '0 16px 14px', padding: 14,
|
||||||
|
border: '1px solid var(--line)', borderRadius: 14,
|
||||||
|
display: 'flex', gap: 14, alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'relative', width: 130, height: 130, flexShrink: 0 }}>
|
||||||
|
<Donut data={DONUT_DATA} size={130} thickness={22}
|
||||||
|
active={active} onSegment={(i) => {
|
||||||
|
const id = DONUT_DATA[i].id;
|
||||||
|
setFilterCat(filterCat === id ? null : id);
|
||||||
|
setActive(active === i ? null : i);
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex',
|
||||||
|
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Расходы</div>
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 14, fontWeight: 600 }}>67 200 ₽</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{DONUT_DATA.slice(0, 4).map((d, i) => {
|
||||||
|
const pct = Math.round((d.value / SPEND_TOTAL) * 100);
|
||||||
|
return (
|
||||||
|
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: 2, background: d.color }} />
|
||||||
|
<span style={{ flex: 1, color: 'var(--ink)' }}>{d.label}</span>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', color: 'var(--ink-2)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-2)' }}>+ ещё 2 категории →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 16px 8px', alignItems: 'center' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Транзакции</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>{visibleTx.length} операций</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottomsheet-trigger filter (from V2) */}
|
||||||
|
<div
|
||||||
|
onClick={() => { setFilterCat(null); setActive(null); }}
|
||||||
|
style={{
|
||||||
|
margin: '0 16px 0', padding: '10px 12px',
|
||||||
|
border: '1px solid var(--line)', borderRadius: 12,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: 'var(--paper)', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', color: 'var(--ink-2)' }}>{Icons.filter}</span>
|
||||||
|
<span style={{ flex: 1, fontSize: 13, color: 'var(--ink)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{activeCat ? (
|
||||||
|
<>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: 2, background: activeCat.color }} />
|
||||||
|
<span>{activeCat.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Все категории · все типы</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, padding: '2px 6px', borderRadius: 99,
|
||||||
|
background: 'var(--accent-soft)', color: 'var(--accent)', fontWeight: 600,
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
}}>{visibleTx.length}</span>
|
||||||
|
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TX list grouped by day */}
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
{groups.map(g => (
|
||||||
|
<React.Fragment key={g.day}>
|
||||||
|
<DayHeader label={g.day} total={g.total} />
|
||||||
|
{g.items.map(tx => <TxRow key={tx.id} tx={tx} />)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FAB />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// V2 — Account dropdown + hero card + bottomsheet filter
|
||||||
|
// =====================================================================
|
||||||
|
function V2() {
|
||||||
|
const [acc] = React.useState('card');
|
||||||
|
const accObj = ACCOUNTS.find(a => a.id === acc);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
|
||||||
|
{/* Account dropdown header */}
|
||||||
|
<div style={{ padding: '14px 16px 6px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '6px 10px 6px 8px', borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)', background: 'var(--paper)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 24, height: 24, borderRadius: 6, background: 'var(--accent-soft)',
|
||||||
|
color: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>{accObj.icon}</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.15 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>{accObj.label}</span>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--ink-2)' }}>4 счёта · переключить</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--ink-2)', display: 'flex', gap: 6 }}>
|
||||||
|
{Icons.search}{Icons.bell}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero balance card — editorial */}
|
||||||
|
<div style={{
|
||||||
|
margin: '14px 16px 16px',
|
||||||
|
padding: '18px 18px 16px',
|
||||||
|
background: 'var(--ink)', color: 'var(--paper)',
|
||||||
|
borderRadius: 18, position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 10, opacity: 0.6, letterSpacing: 0.6, textTransform: 'uppercase' }}>Баланс счёта</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 30, fontWeight: 600,
|
||||||
|
letterSpacing: -0.6, marginTop: 2, marginBottom: 14,
|
||||||
|
}}>142 500 ₽</div>
|
||||||
|
|
||||||
|
{/* Inline sparkline */}
|
||||||
|
<svg width="100%" height="40" viewBox="0 0 280 40" style={{ marginBottom: 10 }}>
|
||||||
|
<path d="M0 28 L20 24 L40 26 L60 18 L80 22 L100 14 L120 18 L140 10 L160 14 L180 8 L200 12 L220 6 L240 10 L260 4 L280 8"
|
||||||
|
fill="none" stroke="var(--accent)" strokeWidth="1.5" />
|
||||||
|
<path d="M0 28 L20 24 L40 26 L60 18 L80 22 L100 14 L120 18 L140 10 L160 14 L180 8 L200 12 L220 6 L240 10 L260 4 L280 8 L280 40 L0 40 Z"
|
||||||
|
fill="var(--accent)" opacity="0.18" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, opacity: 0.6, textTransform: 'uppercase', letterSpacing: 0.6 }}>Доход</div>
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600 }}>+95 000</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, opacity: 0.6, textTransform: 'uppercase', letterSpacing: 0.6 }}>Расход</div>
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600 }}>−67 200</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, opacity: 0.6, textTransform: 'uppercase', letterSpacing: 0.6 }}>Остаток</div>
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600 }}>27 800</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donut centered, with big center stat */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 16px 8px', alignItems: 'baseline' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Расходы по категориям</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>Май</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', position: 'relative', marginBottom: 4 }}>
|
||||||
|
<div style={{ position: 'relative', width: 160, height: 160 }}>
|
||||||
|
<Donut data={DONUT_DATA} size={160} thickness={20} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex',
|
||||||
|
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Всего</div>
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 19, fontWeight: 600, color: 'var(--ink)' }}>67 200 ₽</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-2)', marginTop: 2 }}>6 категорий</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter row (sheet trigger) */}
|
||||||
|
<div style={{
|
||||||
|
margin: '6px 16px 0', padding: '10px 12px',
|
||||||
|
border: '1px solid var(--line)', borderRadius: 12,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: 'var(--paper)',
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', color: 'var(--ink-2)' }}>{Icons.filter}</span>
|
||||||
|
<span style={{ flex: 1, fontSize: 13, color: 'var(--ink)' }}>Все категории · все типы</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, padding: '2px 6px', borderRadius: 99,
|
||||||
|
background: 'var(--accent-soft)', color: 'var(--accent)', fontWeight: 600,
|
||||||
|
}}>132</span>
|
||||||
|
<span style={{ color: 'var(--ink-2)', display: 'flex' }}>{Icons.chev}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact TX list */}
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{TX.slice(0, 5).map(tx => <TxRow key={tx.id} tx={tx} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sheet peek note */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 12, right: 12, bottom: 90,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<NoteArrow rot={-15} len={32} />
|
||||||
|
<Note>Тап — открывает bottom sheet с фильтрами</Note>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FAB />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// V3 — Swipeable account cards carousel + stacked bar + chips
|
||||||
|
// =====================================================================
|
||||||
|
function V3() {
|
||||||
|
const [filterCat, setFilterCat] = React.useState(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{ padding: '14px 16px 4px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', letterSpacing: -0.3 }}>Привет, Аня</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--ink-2)' }}>Май 2026 · 27 800 ₽ свободно</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, color: 'var(--ink-2)' }}>{Icons.bell}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account cards carousel (peek style) */}
|
||||||
|
<div style={{ marginTop: 12, marginBottom: 12, position: 'relative' }}>
|
||||||
|
<HScroll gap={10}>
|
||||||
|
{ACCOUNTS.map((a, i) => (
|
||||||
|
<div key={a.id} style={{
|
||||||
|
width: 220, flexShrink: 0,
|
||||||
|
padding: '14px 14px 12px',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid ' + (i === 1 ? 'var(--ink)' : 'var(--line)'),
|
||||||
|
background: i === 1 ? 'var(--ink)' : 'var(--paper)',
|
||||||
|
color: i === 1 ? 'var(--paper)' : 'var(--ink)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, borderRadius: 8,
|
||||||
|
background: i === 1 ? 'rgba(255,255,255,0.12)' : 'var(--accent-soft)',
|
||||||
|
color: i === 1 ? 'var(--paper)' : 'var(--accent)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>{a.icon}</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, opacity: i === 1 ? 0.6 : 0.5,
|
||||||
|
textTransform: 'uppercase', letterSpacing: 0.6,
|
||||||
|
}}>{a.id === 'all' ? 'сводно' : 'счёт'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.7, marginBottom: 2 }}>{a.label}</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 20, fontWeight: 600,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
}}>{fmtNoCur(a.balance)} ₽</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</HScroll>
|
||||||
|
{/* Page dots */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 5, marginTop: 10 }}>
|
||||||
|
{ACCOUNTS.map((_, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: i === 1 ? 14 : 5, height: 5, borderRadius: 99,
|
||||||
|
background: i === 1 ? 'var(--ink)' : 'var(--line-2)',
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2x2 KPI */}
|
||||||
|
<div style={{
|
||||||
|
margin: '0 16px 14px', padding: 14,
|
||||||
|
border: '1px solid var(--line)', borderRadius: 14,
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14,
|
||||||
|
}}>
|
||||||
|
<KPI label="Доходы" value="+95 000 ₽" accent="var(--pos)" hint="3 транзакции" />
|
||||||
|
<KPI label="Расходы" value="−67 200 ₽" accent="var(--neg)" hint="42 транзакции" />
|
||||||
|
<KPI label="Бюджет" value="95 000 ₽" hint="лимит на месяц" />
|
||||||
|
<KPI label="Осталось" value="27 800 ₽" hint="на 9 дней" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stacked bar — alt visualization */}
|
||||||
|
<div style={{ margin: '0 16px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Структура расходов</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>67 200 ₽</span>
|
||||||
|
</div>
|
||||||
|
<StackBar data={DONUT_DATA} />
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 10 }}>
|
||||||
|
{DONUT_DATA.map(d => {
|
||||||
|
const pct = Math.round((d.value / SPEND_TOTAL) * 100);
|
||||||
|
return (
|
||||||
|
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11 }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: 2, background: d.color }} />
|
||||||
|
<span style={{ color: 'var(--ink)' }}>{d.label}</span>
|
||||||
|
<span style={{ color: 'var(--ink-2)', fontFamily: 'JetBrains Mono, monospace' }}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline category chips */}
|
||||||
|
<div style={{ padding: '0 16px 6px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Последние операции</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>Все →</span>
|
||||||
|
</div>
|
||||||
|
<HScroll>
|
||||||
|
<CategoryChip active={!filterCat} onClick={() => setFilterCat(null)} dense />
|
||||||
|
{CATS.slice(0, 5).map(c => (
|
||||||
|
<CategoryChip key={c.id} cat={c} dense
|
||||||
|
active={filterCat === c.id}
|
||||||
|
onClick={() => setFilterCat(filterCat === c.id ? null : c.id)} />
|
||||||
|
))}
|
||||||
|
</HScroll>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{TX.slice(0, 4).map(tx => <TxRow key={tx.id} tx={tx} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FAB />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// V4 — Editorial title + tiny tab pills (top-right) + centered donut KPI
|
||||||
|
// =====================================================================
|
||||||
|
function V4() {
|
||||||
|
const [acc, setAcc] = React.useState('all');
|
||||||
|
const [filterCat, setFilterCat] = React.useState(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
|
||||||
|
{/* Top: editorial title + tiny tabs */}
|
||||||
|
<div style={{
|
||||||
|
padding: '18px 16px 4px',
|
||||||
|
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>2026</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 38, fontWeight: 600, color: 'var(--ink)',
|
||||||
|
letterSpacing: -1, lineHeight: 1, marginTop: 2,
|
||||||
|
}}>Май.</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--ink-2)', display: 'flex', gap: 6 }}>
|
||||||
|
{Icons.eye}{Icons.bell}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tiny segmented tabs */}
|
||||||
|
<div style={{ padding: '14px 16px 0' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', padding: 3, borderRadius: 99,
|
||||||
|
background: 'var(--card-soft)', border: '1px solid var(--line)',
|
||||||
|
}}>
|
||||||
|
{ACCOUNTS.map(a => (
|
||||||
|
<div key={a.id} onClick={() => setAcc(a.id)} style={{
|
||||||
|
padding: '5px 11px', borderRadius: 99, fontSize: 12,
|
||||||
|
background: acc === a.id ? 'var(--paper)' : 'transparent',
|
||||||
|
boxShadow: acc === a.id ? '0 1px 3px rgba(0,0,0,0.08)' : 'none',
|
||||||
|
fontWeight: acc === a.id ? 600 : 400,
|
||||||
|
color: acc === a.id ? 'var(--ink)' : 'var(--ink-2)',
|
||||||
|
}}>{a.short}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number-first month summary */}
|
||||||
|
<div style={{ padding: '16px 16px 10px', display: 'flex', gap: 18 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Баланс</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 24, fontWeight: 600,
|
||||||
|
color: 'var(--ink)', letterSpacing: -0.4,
|
||||||
|
}}>184 320 ₽</div>
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: 'var(--pos)', display: 'flex' }}>{Icons.arrowUp}</span>
|
||||||
|
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>95 000 ₽</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>доход</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: 'var(--neg)', display: 'flex' }}>{Icons.arrowDn}</span>
|
||||||
|
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>67 200 ₽</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>расход</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--ink-2)' }}>=</span>
|
||||||
|
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>27 800 ₽</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>остаток</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Donut with big center number */}
|
||||||
|
<div style={{ position: 'relative', width: 130, height: 130 }}>
|
||||||
|
<Donut data={DONUT_DATA} size={130} thickness={14} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex',
|
||||||
|
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 17, fontWeight: 600,
|
||||||
|
color: 'var(--neg)', letterSpacing: -0.3,
|
||||||
|
}}>−67.2K</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>траты</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ height: 1, background: 'var(--line)', margin: '6px 16px 0' }} />
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<div style={{ padding: '14px 16px 8px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>Операции</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--ink-2)' }}>132 в мае</span>
|
||||||
|
</div>
|
||||||
|
<HScroll>
|
||||||
|
<CategoryChip active={!filterCat} onClick={() => setFilterCat(null)} dense />
|
||||||
|
{CATS.map(c => (
|
||||||
|
<CategoryChip key={c.id} cat={c} dense
|
||||||
|
active={filterCat === c.id}
|
||||||
|
onClick={() => setFilterCat(filterCat === c.id ? null : c.id)} />
|
||||||
|
))}
|
||||||
|
</HScroll>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<DayHeader label="Сегодня · 24 мая" total={2882} />
|
||||||
|
{TX.slice(0, 3).map(tx => <TxRow key={tx.id} tx={tx} />)}
|
||||||
|
<DayHeader label="Вчера · 23 мая" total={1770} />
|
||||||
|
{TX.slice(3, 5).map(tx => <TxRow key={tx.id} tx={tx} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FAB />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// V5 — Mini account grid + 2x2 KPI + donut with chip-legend (dual filter)
|
||||||
|
// =====================================================================
|
||||||
|
function V5() {
|
||||||
|
const [acc, setAcc] = React.useState('card');
|
||||||
|
const [filterCat, setFilterCat] = React.useState(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--paper)', minHeight: '100%', position: 'relative', paddingBottom: 80 }}>
|
||||||
|
<div style={{ padding: '14px 16px 4px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>Бюджет · Май 2026</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink)' }}>Обзор</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--ink-2)', display: 'flex', gap: 6 }}>{Icons.search}{Icons.bell}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini account grid 2x2 */}
|
||||||
|
<div style={{
|
||||||
|
margin: '12px 16px 0',
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8,
|
||||||
|
}}>
|
||||||
|
{ACCOUNTS.map(a => {
|
||||||
|
const isActive = a.id === acc;
|
||||||
|
return (
|
||||||
|
<div key={a.id} onClick={() => setAcc(a.id)} style={{
|
||||||
|
padding: '10px 12px', borderRadius: 12,
|
||||||
|
border: '1px solid ' + (isActive ? 'var(--ink)' : 'var(--line)'),
|
||||||
|
background: isActive ? 'var(--ink)' : 'var(--paper)',
|
||||||
|
color: isActive ? 'var(--paper)' : 'var(--ink)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ display: 'flex', opacity: isActive ? 1 : 0.7 }}>{a.icon}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.2 }}>{a.label}</span>
|
||||||
|
{isActive && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 6, right: 8,
|
||||||
|
width: 6, height: 6, borderRadius: 99, background: 'var(--accent)',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 14, fontWeight: 600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
}}>{fmtNoCur(a.balance)} ₽</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2x2 KPI grid */}
|
||||||
|
<div style={{
|
||||||
|
margin: '12px 16px 0',
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||||
|
border: '1px solid var(--line)', borderRadius: 12, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ l: 'Доходы', v: '+95 000 ₽', c: 'var(--pos)' },
|
||||||
|
{ l: 'Расходы', v: '−67 200 ₽', c: 'var(--neg)' },
|
||||||
|
{ l: 'Остаток', v: '27 800 ₽', c: 'var(--ink)' },
|
||||||
|
{ l: 'Бюджет', v: '70% / 95K', c: 'var(--ink)' },
|
||||||
|
].map((k, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
padding: 12,
|
||||||
|
borderRight: i % 2 === 0 ? '1px solid var(--line)' : 'none',
|
||||||
|
borderBottom: i < 2 ? '1px solid var(--line)' : 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{k.l}</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace', fontSize: 15, fontWeight: 600,
|
||||||
|
color: k.c, marginTop: 2,
|
||||||
|
}}>{k.v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donut + chip-legend */}
|
||||||
|
<div style={{
|
||||||
|
margin: '12px 16px 12px', padding: 12,
|
||||||
|
border: '1px solid var(--line)', borderRadius: 14,
|
||||||
|
display: 'flex', gap: 12, alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'relative', width: 108, height: 108, flexShrink: 0 }}>
|
||||||
|
<Donut data={DONUT_DATA} size={108} thickness={16}
|
||||||
|
active={filterCat ? DONUT_DATA.findIndex(d => d.id === filterCat) : null}
|
||||||
|
onSegment={i => setFilterCat(filterCat === DONUT_DATA[i].id ? null : DONUT_DATA[i].id)} />
|
||||||
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' }}>
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 13, fontWeight: 600 }}>67.2K</div>
|
||||||
|
<div style={{ fontSize: 8, color: 'var(--ink-2)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Расходы</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||||
|
{DONUT_DATA.map(d => {
|
||||||
|
const active = filterCat === d.id;
|
||||||
|
return (
|
||||||
|
<div key={d.id}
|
||||||
|
onClick={() => setFilterCat(active ? null : d.id)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '3px 8px', borderRadius: 99, fontSize: 11,
|
||||||
|
border: '1px solid ' + (active ? 'var(--ink)' : 'var(--line)'),
|
||||||
|
background: active ? 'var(--ink)' : 'var(--paper)',
|
||||||
|
color: active ? 'var(--paper)' : 'var(--ink)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 6, height: 6, borderRadius: 99, background: d.color }} />
|
||||||
|
<span>{d.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter + list */}
|
||||||
|
<div style={{ padding: '0 16px 6px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)' }}>
|
||||||
|
Операции {filterCat && <span style={{ color: 'var(--ink-2)', fontWeight: 400 }}>· {CATS.find(c => c.id === filterCat)?.label}</span>}
|
||||||
|
</div>
|
||||||
|
{filterCat && (
|
||||||
|
<span onClick={() => setFilterCat(null)} style={{ fontSize: 11, color: 'var(--accent)', cursor: 'pointer' }}>Сбросить</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{(filterCat ? TX.filter(t => t.cat === filterCat) : TX).slice(0, 4).map(tx => <TxRow key={tx.id} tx={tx} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FAB />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { V1, V2, V3, V4, V5, CategoryChip, KPI, MonthChip, HScroll });
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const MyApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
// This widget is the root of your application.
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Flutter Demo',
|
||||||
|
theme: ThemeData(
|
||||||
|
// This is the theme of your application.
|
||||||
|
//
|
||||||
|
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||||
|
// the application has a purple toolbar. Then, without quitting the app,
|
||||||
|
// try changing the seedColor in the colorScheme below to Colors.green
|
||||||
|
// and then invoke "hot reload" (save your changes or press the "hot
|
||||||
|
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||||
|
// the command line to start the app).
|
||||||
|
//
|
||||||
|
// Notice that the counter didn't reset back to zero; the application
|
||||||
|
// state is not lost during the reload. To reset the state, use hot
|
||||||
|
// restart instead.
|
||||||
|
//
|
||||||
|
// This works for code too, not just values: Most code changes can be
|
||||||
|
// tested with just a hot reload.
|
||||||
|
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||||
|
),
|
||||||
|
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyHomePage extends StatefulWidget {
|
||||||
|
const MyHomePage({super.key, required this.title});
|
||||||
|
|
||||||
|
// This widget is the home page of your application. It is stateful, meaning
|
||||||
|
// that it has a State object (defined below) that contains fields that affect
|
||||||
|
// how it looks.
|
||||||
|
|
||||||
|
// This class is the configuration for the state. It holds the values (in this
|
||||||
|
// case the title) provided by the parent (in this case the App widget) and
|
||||||
|
// used by the build method of the State. Fields in a Widget subclass are
|
||||||
|
// always marked "final".
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyHomePage> createState() => _MyHomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
|
int _counter = 0;
|
||||||
|
|
||||||
|
void _incrementCounter() {
|
||||||
|
setState(() {
|
||||||
|
// This call to setState tells the Flutter framework that something has
|
||||||
|
// changed in this State, which causes it to rerun the build method below
|
||||||
|
// so that the display can reflect the updated values. If we changed
|
||||||
|
// _counter without calling setState(), then the build method would not be
|
||||||
|
// called again, and so nothing would appear to happen.
|
||||||
|
_counter++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// This method is rerun every time setState is called, for instance as done
|
||||||
|
// by the _incrementCounter method above.
|
||||||
|
//
|
||||||
|
// The Flutter framework has been optimized to make rerunning build methods
|
||||||
|
// fast, so that you can just rebuild anything that needs updating rather
|
||||||
|
// than having to individually change instances of widgets.
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
// TRY THIS: Try changing the color here to a specific color (to
|
||||||
|
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||||
|
// change color while the other colors stay the same.
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
|
// Here we take the value from the MyHomePage object that was created by
|
||||||
|
// the App.build method, and use it to set our appbar title.
|
||||||
|
title: Text(widget.title),
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
// Center is a layout widget. It takes a single child and positions it
|
||||||
|
// in the middle of the parent.
|
||||||
|
child: Column(
|
||||||
|
// Column is also a layout widget. It takes a list of children and
|
||||||
|
// arranges them vertically. By default, it sizes itself to fit its
|
||||||
|
// children horizontally, and tries to be as tall as its parent.
|
||||||
|
//
|
||||||
|
// Column has various properties to control how it sizes itself and
|
||||||
|
// how it positions its children. Here we use mainAxisAlignment to
|
||||||
|
// center the children vertically; the main axis here is the vertical
|
||||||
|
// axis because Columns are vertical (the cross axis would be
|
||||||
|
// horizontal).
|
||||||
|
//
|
||||||
|
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||||
|
// action in the IDE, or press "p" in the console), to see the
|
||||||
|
// wireframe for each widget.
|
||||||
|
mainAxisAlignment: .center,
|
||||||
|
children: [
|
||||||
|
const Text('You have pushed the button this many times:'),
|
||||||
|
Text(
|
||||||
|
'$_counter',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _incrementCounter,
|
||||||
|
tooltip: 'Increment',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// Глобальные константы приложения.
|
||||||
|
abstract final class AppConstants {
|
||||||
|
/// Валюта по умолчанию для новых профилей.
|
||||||
|
static const String defaultCurrency = 'RUB';
|
||||||
|
|
||||||
|
/// Первый день месяца по умолчанию (1 = 1-е число).
|
||||||
|
static const int defaultFirstDayOfMonth = 1;
|
||||||
|
|
||||||
|
/// Максимальная длина имени счёта / категории.
|
||||||
|
static const int maxNameLength = 50;
|
||||||
|
|
||||||
|
/// Максимальная длина заметки к транзакции.
|
||||||
|
static const int maxNoteLength = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имена маршрутов (дублируются для удобства автодополнения).
|
||||||
|
abstract final class RouteNames {
|
||||||
|
static const String userSelect = 'user-select';
|
||||||
|
static const String dashboard = 'dashboard';
|
||||||
|
static const String accounts = 'accounts';
|
||||||
|
static const String accountDetail = 'account-detail';
|
||||||
|
static const String categories = 'categories';
|
||||||
|
static const String transactions = 'transactions';
|
||||||
|
static const String transactionAdd = 'transaction-add';
|
||||||
|
static const String transactionEdit = 'transaction-edit';
|
||||||
|
static const String settings = 'settings';
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
|
|
||||||
|
import 'tables/users_table.dart';
|
||||||
|
import 'tables/settings_table.dart';
|
||||||
|
import 'tables/accounts_table.dart';
|
||||||
|
import 'tables/categories_table.dart';
|
||||||
|
import 'tables/transactions_table.dart';
|
||||||
|
import 'daos/users_dao.dart';
|
||||||
|
import 'daos/settings_dao.dart';
|
||||||
|
import 'daos/accounts_dao.dart';
|
||||||
|
import 'daos/categories_dao.dart';
|
||||||
|
import 'daos/transactions_dao.dart';
|
||||||
|
|
||||||
|
part 'app_database.g.dart';
|
||||||
|
|
||||||
|
@DriftDatabase(
|
||||||
|
tables: [
|
||||||
|
UsersTable,
|
||||||
|
SettingsTable,
|
||||||
|
AppPreferencesTable,
|
||||||
|
AccountsTable,
|
||||||
|
CategoriesTable,
|
||||||
|
TransactionsTable,
|
||||||
|
],
|
||||||
|
daos: [
|
||||||
|
UsersDao,
|
||||||
|
SettingsDao,
|
||||||
|
AccountsDao,
|
||||||
|
CategoriesDao,
|
||||||
|
TransactionsDao,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
AppDatabase() : super(_openConnection());
|
||||||
|
|
||||||
|
/// Для тестов — внедрение кастомного executor.
|
||||||
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
onCreate: (m) async {
|
||||||
|
await m.createAll();
|
||||||
|
},
|
||||||
|
onUpgrade: (m, from, to) async {
|
||||||
|
// Будущие миграции добавляются здесь.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
static QueryExecutor _openConnection() {
|
||||||
|
return driftDatabase(name: 'new_budget_db');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AccountType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum AccountType { cash, card, bank, savings }
|
||||||
|
|
||||||
|
class AccountTypeConverter extends TypeConverter<AccountType, String> {
|
||||||
|
const AccountTypeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
AccountType fromSql(String fromDb) =>
|
||||||
|
AccountType.values.firstWhere((e) => e.name == fromDb);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(AccountType value) => value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CategoryType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum CategoryType { income, expense }
|
||||||
|
|
||||||
|
class CategoryTypeConverter extends TypeConverter<CategoryType, String> {
|
||||||
|
const CategoryTypeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
CategoryType fromSql(String fromDb) =>
|
||||||
|
CategoryType.values.firstWhere((e) => e.name == fromDb);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(CategoryType value) => value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TransactionType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum TransactionType { income, expense, transfer }
|
||||||
|
|
||||||
|
class TransactionTypeConverter extends TypeConverter<TransactionType, String> {
|
||||||
|
const TransactionTypeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TransactionType fromSql(String fromDb) =>
|
||||||
|
TransactionType.values.firstWhere((e) => e.name == fromDb);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(TransactionType value) => value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ThemeMode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum AppThemeMode { system, light, dark }
|
||||||
|
|
||||||
|
class AppThemeModeConverter extends TypeConverter<AppThemeMode, String> {
|
||||||
|
const AppThemeModeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppThemeMode fromSql(String fromDb) =>
|
||||||
|
AppThemeMode.values.firstWhere((e) => e.name == fromDb);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(AppThemeMode value) => value.name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../app_database.dart';
|
||||||
|
import '../tables/accounts_table.dart';
|
||||||
|
import '../tables/transactions_table.dart';
|
||||||
|
|
||||||
|
part 'accounts_dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [AccountsTable, TransactionsTable])
|
||||||
|
class AccountsDao extends DatabaseAccessor<AppDatabase>
|
||||||
|
with _$AccountsDaoMixin {
|
||||||
|
AccountsDao(super.db);
|
||||||
|
|
||||||
|
/// Реактивный поток счетов пользователя (не архивных).
|
||||||
|
Stream<List<AccountsTableData>> watchAccountsByUser(int userId) =>
|
||||||
|
(select(accountsTable)
|
||||||
|
..where((t) => t.userId.equals(userId) & t.archived.equals(false))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||||
|
.watch();
|
||||||
|
|
||||||
|
Future<List<AccountsTableData>> getAccountsByUser(int userId) =>
|
||||||
|
(select(accountsTable)
|
||||||
|
..where((t) => t.userId.equals(userId) & t.archived.equals(false)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
Future<AccountsTableData?> findById(int id) =>
|
||||||
|
(select(accountsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||||
|
|
||||||
|
Future<int> insertAccount(AccountsTableCompanion companion) =>
|
||||||
|
into(accountsTable).insert(companion);
|
||||||
|
|
||||||
|
Future<bool> updateAccount(AccountsTableCompanion companion) =>
|
||||||
|
update(accountsTable).replace(companion);
|
||||||
|
|
||||||
|
Future<void> archiveAccount(int id) => (update(accountsTable)
|
||||||
|
..where((t) => t.id.equals(id)))
|
||||||
|
.write(const AccountsTableCompanion(archived: Value(true)));
|
||||||
|
|
||||||
|
/// Реактивный текущий баланс счёта (начальный + сумма транзакций).
|
||||||
|
/// TODO: добавить сложную SQL-агрегацию с учётом типа транзакции.
|
||||||
|
Stream<int> watchAccountBalance(int accountId) {
|
||||||
|
// Stub: возвращает только initialBalance пока не реализована агрегация.
|
||||||
|
return (select(accountsTable)..where((t) => t.id.equals(accountId)))
|
||||||
|
.watchSingleOrNull()
|
||||||
|
.map((a) => a?.initialBalance ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../app_database.dart';
|
||||||
|
import '../tables/categories_table.dart';
|
||||||
|
import '../converters/enum_converters.dart';
|
||||||
|
|
||||||
|
part 'categories_dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [CategoriesTable])
|
||||||
|
class CategoriesDao extends DatabaseAccessor<AppDatabase>
|
||||||
|
with _$CategoriesDaoMixin {
|
||||||
|
CategoriesDao(super.db);
|
||||||
|
|
||||||
|
Stream<List<CategoriesTableData>> watchCategoriesByUser(int userId) =>
|
||||||
|
(select(categoriesTable)
|
||||||
|
..where(
|
||||||
|
(t) => t.userId.equals(userId) & t.archived.equals(false))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.name)]))
|
||||||
|
.watch();
|
||||||
|
|
||||||
|
Stream<List<CategoriesTableData>> watchByType(
|
||||||
|
int userId,
|
||||||
|
CategoryType type,
|
||||||
|
) =>
|
||||||
|
(select(categoriesTable)
|
||||||
|
..where((t) =>
|
||||||
|
t.userId.equals(userId) &
|
||||||
|
t.type.equalsValue(type) &
|
||||||
|
t.archived.equals(false)))
|
||||||
|
.watch();
|
||||||
|
|
||||||
|
Future<List<CategoriesTableData>> getCategoriesByUser(int userId) =>
|
||||||
|
(select(categoriesTable)
|
||||||
|
..where(
|
||||||
|
(t) => t.userId.equals(userId) & t.archived.equals(false)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
Future<CategoriesTableData?> findById(int id) =>
|
||||||
|
(select(categoriesTable)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
Future<int> insertCategory(CategoriesTableCompanion companion) =>
|
||||||
|
into(categoriesTable).insert(companion);
|
||||||
|
|
||||||
|
Future<bool> updateCategory(CategoriesTableCompanion companion) =>
|
||||||
|
update(categoriesTable).replace(companion);
|
||||||
|
|
||||||
|
Future<void> archiveCategory(int id) =>
|
||||||
|
(update(categoriesTable)..where((t) => t.id.equals(id)))
|
||||||
|
.write(const CategoriesTableCompanion(archived: Value(true)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../app_database.dart';
|
||||||
|
import '../tables/settings_table.dart';
|
||||||
|
|
||||||
|
part 'settings_dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [SettingsTable, AppPreferencesTable])
|
||||||
|
class SettingsDao extends DatabaseAccessor<AppDatabase>
|
||||||
|
with _$SettingsDaoMixin {
|
||||||
|
SettingsDao(super.db);
|
||||||
|
|
||||||
|
// ── Settings per user ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Stream<SettingsTableData?> watchSettingsByUser(int userId) =>
|
||||||
|
(select(settingsTable)..where((t) => t.userId.equals(userId)))
|
||||||
|
.watchSingleOrNull();
|
||||||
|
|
||||||
|
Future<SettingsTableData?> getSettingsByUser(int userId) =>
|
||||||
|
(select(settingsTable)..where((t) => t.userId.equals(userId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
Future<void> upsertSettings(SettingsTableCompanion companion) =>
|
||||||
|
into(settingsTable).insertOnConflictUpdate(companion);
|
||||||
|
|
||||||
|
// ── App preferences (key-value) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<String?> getPreference(String key) async {
|
||||||
|
final row = await (select(appPreferencesTable)
|
||||||
|
..where((t) => t.key.equals(key)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setPreference(String key, String value) =>
|
||||||
|
into(appPreferencesTable).insertOnConflictUpdate(
|
||||||
|
AppPreferencesTableCompanion(
|
||||||
|
key: Value(key),
|
||||||
|
value: Value(value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> deletePreference(String key) =>
|
||||||
|
(delete(appPreferencesTable)..where((t) => t.key.equals(key))).go();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../app_database.dart';
|
||||||
|
import '../tables/transactions_table.dart';
|
||||||
|
import '../converters/enum_converters.dart';
|
||||||
|
|
||||||
|
part 'transactions_dao.g.dart';
|
||||||
|
|
||||||
|
/// Фильтр для запросов транзакций.
|
||||||
|
class TransactionFilter {
|
||||||
|
const TransactionFilter({
|
||||||
|
required this.userId,
|
||||||
|
this.accountId,
|
||||||
|
this.categoryId,
|
||||||
|
this.type,
|
||||||
|
this.from,
|
||||||
|
this.to,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int userId;
|
||||||
|
final int? accountId;
|
||||||
|
final int? categoryId;
|
||||||
|
final TransactionType? type;
|
||||||
|
final DateTime? from;
|
||||||
|
final DateTime? to;
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [TransactionsTable])
|
||||||
|
class TransactionsDao extends DatabaseAccessor<AppDatabase>
|
||||||
|
with _$TransactionsDaoMixin {
|
||||||
|
TransactionsDao(super.db);
|
||||||
|
|
||||||
|
/// Реактивный поток транзакций с фильтром.
|
||||||
|
Stream<List<TransactionsTableData>> watchTransactions(
|
||||||
|
TransactionFilter filter) {
|
||||||
|
final query = select(transactionsTable)
|
||||||
|
..where((t) => t.userId.equals(filter.userId))
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.date)]);
|
||||||
|
|
||||||
|
if (filter.accountId != null) {
|
||||||
|
query.where((t) => t.accountId.equals(filter.accountId!));
|
||||||
|
}
|
||||||
|
if (filter.categoryId != null) {
|
||||||
|
query.where((t) => t.categoryId.equals(filter.categoryId!));
|
||||||
|
}
|
||||||
|
if (filter.type != null) {
|
||||||
|
query.where((t) => t.type.equalsValue(filter.type!));
|
||||||
|
}
|
||||||
|
if (filter.from != null) {
|
||||||
|
query.where((t) => t.date.isBiggerOrEqualValue(filter.from!));
|
||||||
|
}
|
||||||
|
if (filter.to != null) {
|
||||||
|
query.where((t) => t.date.isSmallerOrEqualValue(filter.to!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TransactionsTableData>> getTransactions(
|
||||||
|
TransactionFilter filter) =>
|
||||||
|
watchTransactions(filter).first;
|
||||||
|
|
||||||
|
Future<TransactionsTableData?> findById(int id) =>
|
||||||
|
(select(transactionsTable)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
Future<int> insertTransaction(TransactionsTableCompanion companion) =>
|
||||||
|
into(transactionsTable).insert(companion);
|
||||||
|
|
||||||
|
Future<bool> updateTransaction(TransactionsTableCompanion companion) =>
|
||||||
|
update(transactionsTable).replace(companion);
|
||||||
|
|
||||||
|
Future<int> deleteTransaction(int id) =>
|
||||||
|
(delete(transactionsTable)..where((t) => t.id.equals(id))).go();
|
||||||
|
|
||||||
|
/// TODO: сложные SQL-агрегаты: суммы по категориям за период.
|
||||||
|
/// Stream<Map<int, int>> watchTotalsByCategory(int userId, DateTime from, DateTime to)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../app_database.dart';
|
||||||
|
import '../tables/users_table.dart';
|
||||||
|
|
||||||
|
part 'users_dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [UsersTable])
|
||||||
|
class UsersDao extends DatabaseAccessor<AppDatabase> with _$UsersDaoMixin {
|
||||||
|
UsersDao(super.db);
|
||||||
|
|
||||||
|
/// Реактивный поток всех пользователей.
|
||||||
|
Stream<List<UsersTableData>> watchAll() => select(usersTable).watch();
|
||||||
|
|
||||||
|
/// Единоразовый запрос всех пользователей.
|
||||||
|
Future<List<UsersTableData>> getAll() => select(usersTable).get();
|
||||||
|
|
||||||
|
/// Найти пользователя по id.
|
||||||
|
Future<UsersTableData?> findById(int id) =>
|
||||||
|
(select(usersTable)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||||
|
|
||||||
|
/// Создать пользователя. Возвращает id.
|
||||||
|
Future<int> insertUser(UsersTableCompanion companion) =>
|
||||||
|
into(usersTable).insert(companion);
|
||||||
|
|
||||||
|
/// Обновить пользователя.
|
||||||
|
Future<bool> updateUser(UsersTableCompanion companion) =>
|
||||||
|
update(usersTable).replace(companion);
|
||||||
|
|
||||||
|
/// Удалить пользователя.
|
||||||
|
Future<int> deleteUser(int id) =>
|
||||||
|
(delete(usersTable)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../converters/enum_converters.dart';
|
||||||
|
import 'users_table.dart';
|
||||||
|
|
||||||
|
/// Таблица финансовых счетов.
|
||||||
|
class AccountsTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'accounts';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get userId =>
|
||||||
|
integer().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
TextColumn get name => text().withLength(min: 1, max: 50)();
|
||||||
|
|
||||||
|
/// Тип счёта: cash | card | bank | savings.
|
||||||
|
TextColumn get type =>
|
||||||
|
text().map(const AccountTypeConverter()).withDefault(const Constant('cash'))();
|
||||||
|
|
||||||
|
TextColumn get currency => text().withDefault(const Constant('RUB'))();
|
||||||
|
|
||||||
|
/// Начальный баланс в минорных единицах (копейки).
|
||||||
|
IntColumn get initialBalance => integer().withDefault(const Constant(0))();
|
||||||
|
|
||||||
|
IntColumn get iconCode => integer().nullable()();
|
||||||
|
IntColumn get colorValue => integer().nullable()();
|
||||||
|
|
||||||
|
BoolColumn get archived => boolean().withDefault(const Constant(false))();
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../converters/enum_converters.dart';
|
||||||
|
import 'users_table.dart';
|
||||||
|
|
||||||
|
/// Таблица категорий доходов/расходов (с поддержкой иерархии).
|
||||||
|
class CategoriesTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'categories';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get userId =>
|
||||||
|
integer().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
TextColumn get name => text().withLength(min: 1, max: 50)();
|
||||||
|
|
||||||
|
/// Тип: income | expense.
|
||||||
|
TextColumn get type =>
|
||||||
|
text().map(const CategoryTypeConverter()).withDefault(const Constant('expense'))();
|
||||||
|
|
||||||
|
IntColumn get iconCode => integer().nullable()();
|
||||||
|
IntColumn get colorValue => integer().nullable()();
|
||||||
|
|
||||||
|
/// Родительская категория (для подкатегорий). null = корневая.
|
||||||
|
IntColumn get parentId => integer().nullable()();
|
||||||
|
|
||||||
|
BoolColumn get archived => boolean().withDefault(const Constant(false))();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../converters/enum_converters.dart';
|
||||||
|
import 'users_table.dart';
|
||||||
|
|
||||||
|
/// Настройки на профиль пользователя.
|
||||||
|
class SettingsTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'settings';
|
||||||
|
|
||||||
|
/// FK → users.id (1:1 per user).
|
||||||
|
IntColumn get userId =>
|
||||||
|
integer().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
TextColumn get baseCurrency => text().withDefault(const Constant('RUB'))();
|
||||||
|
|
||||||
|
TextColumn get themeMode => text()
|
||||||
|
.map(const AppThemeModeConverter())
|
||||||
|
.withDefault(const Constant('system'))();
|
||||||
|
|
||||||
|
TextColumn get locale => text().withDefault(const Constant('ru'))();
|
||||||
|
|
||||||
|
IntColumn get firstDayOfMonth => integer().withDefault(const Constant(1))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {userId};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Глобальные предпочтения приложения (напр. активный userId).
|
||||||
|
class AppPreferencesTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'app_preferences';
|
||||||
|
|
||||||
|
TextColumn get key => text()();
|
||||||
|
TextColumn get value => text()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {key};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../converters/enum_converters.dart';
|
||||||
|
import 'users_table.dart';
|
||||||
|
import 'accounts_table.dart';
|
||||||
|
import 'categories_table.dart';
|
||||||
|
|
||||||
|
/// Таблица финансовых транзакций.
|
||||||
|
class TransactionsTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'transactions';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get userId =>
|
||||||
|
integer().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
||||||
|
IntColumn get accountId =>
|
||||||
|
integer().references(AccountsTable, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
/// Категория (nullable — для переводов).
|
||||||
|
IntColumn get categoryId => integer()
|
||||||
|
.references(CategoriesTable, #id, onDelete: KeyAction.setNull)
|
||||||
|
.nullable()();
|
||||||
|
|
||||||
|
/// Тип: income | expense | transfer.
|
||||||
|
TextColumn get type =>
|
||||||
|
text().map(const TransactionTypeConverter()).withDefault(const Constant('expense'))();
|
||||||
|
|
||||||
|
/// Сумма в минорных единицах (всегда положительная).
|
||||||
|
IntColumn get amount => integer()();
|
||||||
|
|
||||||
|
DateTimeColumn get date => dateTime()();
|
||||||
|
TextColumn get note => text().withLength(max: 255).nullable()();
|
||||||
|
|
||||||
|
/// Для типа transfer: целевой счёт.
|
||||||
|
IntColumn get transferToAccountId => integer().nullable()();
|
||||||
|
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
/// Таблица профилей пользователей.
|
||||||
|
class UsersTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'users';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get name => text().withLength(min: 1, max: 50)();
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// Базовый класс ошибок домена.
|
||||||
|
sealed class Failure {
|
||||||
|
const Failure(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ошибка базы данных.
|
||||||
|
final class DatabaseFailure extends Failure {
|
||||||
|
const DatabaseFailure(super.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сущность не найдена.
|
||||||
|
final class NotFoundFailure extends Failure {
|
||||||
|
const NotFoundFailure(super.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Нарушение бизнес-правила (валидация).
|
||||||
|
final class ValidationFailure extends Failure {
|
||||||
|
const ValidationFailure(super.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Неизвестная / непредвиденная ошибка.
|
||||||
|
final class UnknownFailure extends Failure {
|
||||||
|
const UnknownFailure(super.message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/// Денежная сумма хранится как целые минорные единицы (копейки/центы),
|
||||||
|
/// чтобы исключить ошибки арифметики с плавающей точкой.
|
||||||
|
class Money {
|
||||||
|
const Money(this.minorUnits, {required this.currency});
|
||||||
|
|
||||||
|
final int minorUnits;
|
||||||
|
final String currency;
|
||||||
|
|
||||||
|
/// Количество знаков после запятой для валюты (по умолчанию 2).
|
||||||
|
static int _decimalsFor(String currency) {
|
||||||
|
const zeroDecimal = {'JPY', 'KRW', 'VND'};
|
||||||
|
return zeroDecimal.contains(currency.toUpperCase()) ? 0 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get amount {
|
||||||
|
final decimals = _decimalsFor(currency);
|
||||||
|
return minorUnits / _pow10(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
Money operator +(Money other) {
|
||||||
|
assert(currency == other.currency, 'Cannot add different currencies');
|
||||||
|
return Money(minorUnits + other.minorUnits, currency: currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
Money operator -(Money other) {
|
||||||
|
assert(currency == other.currency, 'Cannot subtract different currencies');
|
||||||
|
return Money(minorUnits - other.minorUnits, currency: currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
Money operator *(num factor) =>
|
||||||
|
Money((minorUnits * factor).round(), currency: currency);
|
||||||
|
|
||||||
|
bool operator >(Money other) => minorUnits > other.minorUnits;
|
||||||
|
bool operator <(Money other) => minorUnits < other.minorUnits;
|
||||||
|
bool operator >=(Money other) => minorUnits >= other.minorUnits;
|
||||||
|
bool operator <=(Money other) => minorUnits <= other.minorUnits;
|
||||||
|
|
||||||
|
Money get abs => Money(minorUnits.abs(), currency: currency);
|
||||||
|
|
||||||
|
static Money zero(String currency) => Money(0, currency: currency);
|
||||||
|
|
||||||
|
static Money fromAmount(double amount, {required String currency}) {
|
||||||
|
final decimals = _decimalsFor(currency);
|
||||||
|
return Money((amount * _pow10(decimals)).round(), currency: currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _pow10(int n) {
|
||||||
|
var result = 1;
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
result *= 10;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is Money &&
|
||||||
|
other.minorUnits == minorUnits &&
|
||||||
|
other.currency == currency;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(minorUnits, currency);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$currency ${amount.toStringAsFixed(_decimalsFor(currency))}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../database/app_database.dart';
|
||||||
|
|
||||||
|
part 'database_provider.g.dart';
|
||||||
|
|
||||||
|
/// Singleton базы данных. keepAlive=true — живёт всё время работы приложения.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
AppDatabase appDatabase(AppDatabaseRef ref) {
|
||||||
|
final db = AppDatabase();
|
||||||
|
ref.onDispose(db.close);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../data/repositories/account_repository_impl.dart';
|
||||||
|
import '../domain/repositories/account_repository.dart';
|
||||||
|
|
||||||
|
part 'account_providers.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
AccountRepository accountRepository(AccountRepositoryRef ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
return AccountRepositoryImpl(db.accountsDao);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../domain/entities/account.dart';
|
||||||
|
import 'account_providers.dart';
|
||||||
|
|
||||||
|
part 'accounts_controller.g.dart';
|
||||||
|
|
||||||
|
/// Реактивный список счетов для активного пользователя.
|
||||||
|
@riverpod
|
||||||
|
Stream<List<Account>> accountsStream(AccountsStreamRef ref, int userId) =>
|
||||||
|
ref.watch(accountRepositoryProvider).watchByUser(userId);
|
||||||
|
|
||||||
|
/// Текущий баланс счёта.
|
||||||
|
@riverpod
|
||||||
|
Stream<int> accountBalance(AccountBalanceRef ref, int accountId) =>
|
||||||
|
ref.watch(accountRepositoryProvider).watchBalance(accountId);
|
||||||
|
|
||||||
|
/// Контроллер CRUD-операций над счетами.
|
||||||
|
@riverpod
|
||||||
|
class AccountsController extends _$AccountsController {
|
||||||
|
@override
|
||||||
|
AsyncValue<void> build() => const AsyncData(null);
|
||||||
|
|
||||||
|
Future<Account> createAccount({
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required AccountType type,
|
||||||
|
required String currency,
|
||||||
|
int initialBalance = 0,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => ref.read(accountRepositoryProvider).create(
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
currency: currency,
|
||||||
|
initialBalance: initialBalance,
|
||||||
|
iconCode: iconCode,
|
||||||
|
colorValue: colorValue,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
state = result.hasError
|
||||||
|
? AsyncError(result.error!, StackTrace.current)
|
||||||
|
: const AsyncData(null);
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Account> updateAccount(Account account) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => ref.read(accountRepositoryProvider).update(account),
|
||||||
|
);
|
||||||
|
state = result.hasError
|
||||||
|
? AsyncError(result.error!, StackTrace.current)
|
||||||
|
: const AsyncData(null);
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> archiveAccount(int id) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(
|
||||||
|
() => ref.read(accountRepositoryProvider).archive(id),
|
||||||
|
).then((_) => const AsyncData(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../domain/entities/account.dart';
|
||||||
|
|
||||||
|
extension AccountMapper on AccountsTableData {
|
||||||
|
Account toDomain() => Account(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
currency: currency,
|
||||||
|
initialBalance: initialBalance,
|
||||||
|
iconCode: iconCode,
|
||||||
|
colorValue: colorValue,
|
||||||
|
archived: archived,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../core/database/daos/accounts_dao.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../../domain/entities/account.dart';
|
||||||
|
import '../../domain/repositories/account_repository.dart';
|
||||||
|
import '../mappers/account_mapper.dart';
|
||||||
|
|
||||||
|
class AccountRepositoryImpl implements AccountRepository {
|
||||||
|
const AccountRepositoryImpl(this._dao);
|
||||||
|
final AccountsDao _dao;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Account>> watchByUser(int userId) =>
|
||||||
|
_dao.watchAccountsByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Account?> findById(int id) async {
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row?.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Account> create({
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required AccountType type,
|
||||||
|
required String currency,
|
||||||
|
int initialBalance = 0,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
}) async {
|
||||||
|
final id = await _dao.insertAccount(AccountsTableCompanion.insert(
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
type: Value(type),
|
||||||
|
currency: Value(currency),
|
||||||
|
initialBalance: Value(initialBalance),
|
||||||
|
iconCode: Value(iconCode),
|
||||||
|
colorValue: Value(colorValue),
|
||||||
|
));
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Account> update(Account account) async {
|
||||||
|
await _dao.updateAccount(AccountsTableCompanion(
|
||||||
|
id: Value(account.id),
|
||||||
|
name: Value(account.name),
|
||||||
|
type: Value(account.type),
|
||||||
|
currency: Value(account.currency),
|
||||||
|
initialBalance: Value(account.initialBalance),
|
||||||
|
iconCode: Value(account.iconCode),
|
||||||
|
colorValue: Value(account.colorValue),
|
||||||
|
archived: Value(account.archived),
|
||||||
|
));
|
||||||
|
final row = await _dao.findById(account.id);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> archive(int id) => _dao.archiveAccount(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<int> watchBalance(int accountId) => _dao.watchAccountBalance(accountId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
|
||||||
|
part 'account.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class Account with _$Account {
|
||||||
|
const factory Account({
|
||||||
|
required int id,
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required AccountType type,
|
||||||
|
required String currency,
|
||||||
|
required int initialBalance,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
required bool archived,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) = _Account;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import '../entities/account.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
|
||||||
|
abstract interface class AccountRepository {
|
||||||
|
Stream<List<Account>> watchByUser(int userId);
|
||||||
|
Future<Account?> findById(int id);
|
||||||
|
Future<Account> create({
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required AccountType type,
|
||||||
|
required String currency,
|
||||||
|
int initialBalance = 0,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
});
|
||||||
|
Future<Account> update(Account account);
|
||||||
|
Future<void> archive(int id);
|
||||||
|
|
||||||
|
/// Текущий баланс счёта (начальный + агрегат транзакций).
|
||||||
|
Stream<int> watchBalance(int accountId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../domain/entities/category.dart';
|
||||||
|
import 'category_providers.dart';
|
||||||
|
|
||||||
|
part 'categories_controller.g.dart';
|
||||||
|
|
||||||
|
/// Реактивный список категорий для активного пользователя.
|
||||||
|
@riverpod
|
||||||
|
Stream<List<Category>> categoriesStream(CategoriesStreamRef ref, int userId) =>
|
||||||
|
ref.watch(categoryRepositoryProvider).watchByUser(userId);
|
||||||
|
|
||||||
|
/// Реактивный список категорий, фильтрованный по типу (income / expense).
|
||||||
|
@riverpod
|
||||||
|
Stream<List<Category>> categoriesByTypeStream(
|
||||||
|
CategoriesByTypeStreamRef ref,
|
||||||
|
int userId,
|
||||||
|
CategoryType type,
|
||||||
|
) =>
|
||||||
|
ref.watch(categoryRepositoryProvider).watchByType(userId, type);
|
||||||
|
|
||||||
|
/// Контроллер CRUD-операций над категориями.
|
||||||
|
@riverpod
|
||||||
|
class CategoriesController extends _$CategoriesController {
|
||||||
|
@override
|
||||||
|
AsyncValue<void> build() => const AsyncData(null);
|
||||||
|
|
||||||
|
Future<Category> createCategory({
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required CategoryType type,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
int? parentId,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => ref.read(categoryRepositoryProvider).create(
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
iconCode: iconCode,
|
||||||
|
colorValue: colorValue,
|
||||||
|
parentId: parentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
state = result.hasError
|
||||||
|
? AsyncError(result.error!, StackTrace.current)
|
||||||
|
: const AsyncData(null);
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Category> updateCategory(Category category) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => ref.read(categoryRepositoryProvider).update(category),
|
||||||
|
);
|
||||||
|
state = result.hasError
|
||||||
|
? AsyncError(result.error!, StackTrace.current)
|
||||||
|
: const AsyncData(null);
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> archiveCategory(int id) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(
|
||||||
|
() => ref.read(categoryRepositoryProvider).archive(id),
|
||||||
|
).then((_) => const AsyncData(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../data/repositories/category_repository_impl.dart';
|
||||||
|
import '../domain/repositories/category_repository.dart';
|
||||||
|
|
||||||
|
part 'category_providers.g.dart';
|
||||||
|
|
||||||
|
/// DI-провайдер репозитория категорий.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
CategoryRepository categoryRepository(CategoryRepositoryRef ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
return CategoryRepositoryImpl(db.categoriesDao);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../domain/entities/category.dart';
|
||||||
|
|
||||||
|
extension CategoryMapper on CategoriesTableData {
|
||||||
|
Category toDomain() => Category(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
iconCode: iconCode,
|
||||||
|
colorValue: colorValue,
|
||||||
|
parentId: parentId,
|
||||||
|
archived: archived,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../core/database/daos/categories_dao.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../../domain/entities/category.dart';
|
||||||
|
import '../../domain/repositories/category_repository.dart';
|
||||||
|
import '../mappers/category_mapper.dart';
|
||||||
|
|
||||||
|
class CategoryRepositoryImpl implements CategoryRepository {
|
||||||
|
const CategoryRepositoryImpl(this._dao);
|
||||||
|
final CategoriesDao _dao;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Category>> watchByUser(int userId) =>
|
||||||
|
_dao.watchCategoriesByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Category>> watchByType(int userId, CategoryType type) =>
|
||||||
|
_dao.watchByType(userId, type).map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Category?> findById(int id) async {
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row?.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Category> create({
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required CategoryType type,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
int? parentId,
|
||||||
|
}) async {
|
||||||
|
final id = await _dao.insertCategory(CategoriesTableCompanion.insert(
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
type: Value(type),
|
||||||
|
iconCode: Value(iconCode),
|
||||||
|
colorValue: Value(colorValue),
|
||||||
|
parentId: Value(parentId),
|
||||||
|
));
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Category> update(Category category) async {
|
||||||
|
await _dao.updateCategory(CategoriesTableCompanion(
|
||||||
|
id: Value(category.id),
|
||||||
|
name: Value(category.name),
|
||||||
|
type: Value(category.type),
|
||||||
|
iconCode: Value(category.iconCode),
|
||||||
|
colorValue: Value(category.colorValue),
|
||||||
|
parentId: Value(category.parentId),
|
||||||
|
archived: Value(category.archived),
|
||||||
|
));
|
||||||
|
final row = await _dao.findById(category.id);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> archive(int id) => _dao.archiveCategory(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
|
||||||
|
part 'category.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class Category with _$Category {
|
||||||
|
const factory Category({
|
||||||
|
required int id,
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required CategoryType type,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
int? parentId,
|
||||||
|
required bool archived,
|
||||||
|
}) = _Category;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../entities/category.dart';
|
||||||
|
|
||||||
|
abstract interface class CategoryRepository {
|
||||||
|
Stream<List<Category>> watchByUser(int userId);
|
||||||
|
Stream<List<Category>> watchByType(int userId, CategoryType type);
|
||||||
|
Future<Category?> findById(int id);
|
||||||
|
Future<Category> create({
|
||||||
|
required int userId,
|
||||||
|
required String name,
|
||||||
|
required CategoryType type,
|
||||||
|
int? iconCode,
|
||||||
|
int? colorValue,
|
||||||
|
int? parentId,
|
||||||
|
});
|
||||||
|
Future<Category> update(Category category);
|
||||||
|
Future<void> archive(int id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../domain/entities/settings.dart';
|
||||||
|
import '../domain/repositories/settings_repository.dart';
|
||||||
|
import 'settings_providers.dart';
|
||||||
|
|
||||||
|
part 'settings_controller.g.dart';
|
||||||
|
|
||||||
|
/// Контроллер настроек конкретного профиля.
|
||||||
|
///
|
||||||
|
/// Использование:
|
||||||
|
/// ```dart
|
||||||
|
/// // Наблюдение за состоянием
|
||||||
|
/// final settings = ref.watch(settingsControllerProvider(userId));
|
||||||
|
///
|
||||||
|
/// // Обновление
|
||||||
|
/// ref.read(settingsControllerProvider(userId).notifier).setThemeMode(AppThemeMode.dark);
|
||||||
|
/// ```
|
||||||
|
@riverpod
|
||||||
|
class SettingsController extends _$SettingsController {
|
||||||
|
@override
|
||||||
|
Future<Settings> build(int userId) async {
|
||||||
|
// Подписываемся на стрим — при изменении в БД состояние обновится автоматически.
|
||||||
|
final sub = ref.listen(
|
||||||
|
settingsStreamProvider(userId),
|
||||||
|
(_, next) {
|
||||||
|
next.whenData((s) {
|
||||||
|
if (s != null) state = AsyncData(s);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ref.onDispose(sub.close);
|
||||||
|
|
||||||
|
// Первичная загрузка: создаёт дефолтные настройки, если их ещё нет.
|
||||||
|
return ref.read(settingsRepositoryProvider).ensureDefaults(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Точечные обновления ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> setBaseCurrency(String currency) => _update(
|
||||||
|
(repo) => repo.updateBaseCurrency(userId, currency),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> setThemeMode(AppThemeMode themeMode) => _update(
|
||||||
|
(repo) => repo.updateThemeMode(userId, themeMode),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> setLocale(String locale) => _update(
|
||||||
|
(repo) => repo.updateLocale(userId, locale),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> setFirstDayOfMonth(int day) => _update(
|
||||||
|
(repo) => repo.updateFirstDayOfMonth(userId, day),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Полная перезапись настроек.
|
||||||
|
Future<void> saveSettings(Settings settings) => _update(
|
||||||
|
(repo) => repo.upsertSettings(settings),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _update(
|
||||||
|
Future<void> Function(SettingsRepository repo) action,
|
||||||
|
) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(() async {
|
||||||
|
final repo = ref.read(settingsRepositoryProvider);
|
||||||
|
await action(repo);
|
||||||
|
// Читаем свежее состояние из БД после изменения.
|
||||||
|
return repo.ensureDefaults(userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../data/repositories/settings_repository_impl.dart';
|
||||||
|
import '../domain/entities/settings.dart';
|
||||||
|
import '../domain/repositories/settings_repository.dart';
|
||||||
|
|
||||||
|
part 'settings_providers.g.dart';
|
||||||
|
|
||||||
|
/// DI-провайдер репозитория настроек.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
SettingsRepository settingsRepository(SettingsRepositoryRef ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
return SettingsRepositoryImpl(db.settingsDao);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Реактивный поток настроек для конкретного пользователя.
|
||||||
|
@riverpod
|
||||||
|
Stream<Settings?> settingsStream(SettingsStreamRef ref, int userId) =>
|
||||||
|
ref.watch(settingsRepositoryProvider).watchSettings(userId);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../domain/entities/settings.dart';
|
||||||
|
|
||||||
|
/// Маппер: Drift row ↔ доменная сущность Settings.
|
||||||
|
extension SettingsMapper on SettingsTableData {
|
||||||
|
Settings toDomain() => Settings(
|
||||||
|
userId: userId,
|
||||||
|
baseCurrency: baseCurrency,
|
||||||
|
themeMode: themeMode, // уже AppThemeMode — конвертер в Drift-таблице
|
||||||
|
locale: locale,
|
||||||
|
firstDayOfMonth: firstDayOfMonth,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../../../../core/database/daos/settings_dao.dart';
|
||||||
|
import '../../domain/entities/settings.dart';
|
||||||
|
import '../../domain/repositories/settings_repository.dart';
|
||||||
|
import '../mappers/settings_mapper.dart';
|
||||||
|
|
||||||
|
/// Реализация SettingsRepository поверх Drift SettingsDao.
|
||||||
|
class SettingsRepositoryImpl implements SettingsRepository {
|
||||||
|
const SettingsRepositoryImpl(this._dao);
|
||||||
|
|
||||||
|
final SettingsDao _dao;
|
||||||
|
|
||||||
|
// ── Чтение ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Settings?> watchSettings(int userId) =>
|
||||||
|
_dao.watchSettingsByUser(userId).map((row) => row?.toDomain());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Settings?> getSettings(int userId) async {
|
||||||
|
final row = await _dao.getSettingsByUser(userId);
|
||||||
|
return row?.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Запись ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertSettings(Settings settings) =>
|
||||||
|
_dao.upsertSettings(_toCompanion(settings));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Settings> ensureDefaults(int userId) async {
|
||||||
|
final existing = await getSettings(userId);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
const defaults = _defaultSettings;
|
||||||
|
final initial = Settings(
|
||||||
|
userId: userId,
|
||||||
|
baseCurrency: defaults.baseCurrency,
|
||||||
|
themeMode: defaults.themeMode,
|
||||||
|
locale: defaults.locale,
|
||||||
|
firstDayOfMonth: defaults.firstDayOfMonth,
|
||||||
|
);
|
||||||
|
await upsertSettings(initial);
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Точечные обновления ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateBaseCurrency(int userId, String currency) async {
|
||||||
|
final current = await _requireSettings(userId);
|
||||||
|
await upsertSettings(current.copyWith(baseCurrency: currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateThemeMode(int userId, AppThemeMode themeMode) async {
|
||||||
|
final current = await _requireSettings(userId);
|
||||||
|
await upsertSettings(current.copyWith(themeMode: themeMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateLocale(int userId, String locale) async {
|
||||||
|
final current = await _requireSettings(userId);
|
||||||
|
await upsertSettings(current.copyWith(locale: locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateFirstDayOfMonth(int userId, int day) async {
|
||||||
|
assert(day >= 1 && day <= 28, 'firstDayOfMonth must be between 1 and 28');
|
||||||
|
final current = await _requireSettings(userId);
|
||||||
|
await upsertSettings(current.copyWith(firstDayOfMonth: day));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Возвращает текущие настройки, создавая дефолтные при отсутствии.
|
||||||
|
Future<Settings> _requireSettings(int userId) =>
|
||||||
|
ensureDefaults(userId);
|
||||||
|
|
||||||
|
static SettingsTableCompanion _toCompanion(Settings s) =>
|
||||||
|
SettingsTableCompanion(
|
||||||
|
userId: Value(s.userId),
|
||||||
|
baseCurrency: Value(s.baseCurrency),
|
||||||
|
themeMode: Value(s.themeMode),
|
||||||
|
locale: Value(s.locale),
|
||||||
|
firstDayOfMonth: Value(s.firstDayOfMonth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Дефолтные значения настроек (используются при первом создании).
|
||||||
|
const _defaultSettings = (
|
||||||
|
baseCurrency: 'RUB',
|
||||||
|
themeMode: AppThemeMode.system,
|
||||||
|
locale: 'ru',
|
||||||
|
firstDayOfMonth: 1,
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
|
||||||
|
part 'settings.freezed.dart';
|
||||||
|
|
||||||
|
/// Неизменяемая доменная сущность «Настройки профиля».
|
||||||
|
/// Хранит один набор настроек на одного пользователя (1:1 с user).
|
||||||
|
@freezed
|
||||||
|
abstract class Settings with _$Settings {
|
||||||
|
const factory Settings({
|
||||||
|
/// FK → User.id; одновременно является PK таблицы settings.
|
||||||
|
required int userId,
|
||||||
|
|
||||||
|
/// ISO 4217-код валюты по умолчанию, напр. 'RUB', 'USD'.
|
||||||
|
required String baseCurrency,
|
||||||
|
|
||||||
|
/// Тема оформления приложения.
|
||||||
|
required AppThemeMode themeMode,
|
||||||
|
|
||||||
|
/// BCP 47-тег локали, напр. 'ru', 'en'.
|
||||||
|
required String locale,
|
||||||
|
|
||||||
|
/// Первый день рабочей недели/месячного периода (1 = понедельник/1-е число).
|
||||||
|
required int firstDayOfMonth,
|
||||||
|
}) = _Settings;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../entities/settings.dart';
|
||||||
|
|
||||||
|
/// Контракт репозитория настроек профиля.
|
||||||
|
/// Зависит только от чистого Dart — без Drift, без Flutter.
|
||||||
|
abstract interface class SettingsRepository {
|
||||||
|
/// Реактивный поток настроек пользователя.
|
||||||
|
/// Испускает null, если запись ещё не создана.
|
||||||
|
Stream<Settings?> watchSettings(int userId);
|
||||||
|
|
||||||
|
/// Однократное чтение настроек. null если не существует.
|
||||||
|
Future<Settings?> getSettings(int userId);
|
||||||
|
|
||||||
|
/// Создать или обновить настройки (upsert по userId).
|
||||||
|
Future<void> upsertSettings(Settings settings);
|
||||||
|
|
||||||
|
/// Создать запись с дефолтными значениями, если она ещё не существует.
|
||||||
|
Future<Settings> ensureDefaults(int userId);
|
||||||
|
|
||||||
|
// ── Точечные обновления ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Изменить валюту по умолчанию.
|
||||||
|
Future<void> updateBaseCurrency(int userId, String currency);
|
||||||
|
|
||||||
|
/// Изменить тему оформления.
|
||||||
|
Future<void> updateThemeMode(int userId, AppThemeMode themeMode);
|
||||||
|
|
||||||
|
/// Изменить локаль.
|
||||||
|
Future<void> updateLocale(int userId, String locale);
|
||||||
|
|
||||||
|
/// Изменить первый день месяца/недели (1–28).
|
||||||
|
Future<void> updateFirstDayOfMonth(int userId, int day);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../data/repositories/transaction_repository_impl.dart';
|
||||||
|
import '../domain/repositories/transaction_repository.dart';
|
||||||
|
|
||||||
|
part 'transaction_providers.g.dart';
|
||||||
|
|
||||||
|
/// DI-провайдер репозитория транзакций.
|
||||||
|
///
|
||||||
|
/// keepAlive: репозиторий держит открытые Drift-стримы — не должен пересоздаваться.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
TransactionRepository transactionRepository(TransactionRepositoryRef ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
return TransactionRepositoryImpl(db.transactionsDao);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../domain/entities/transaction.dart';
|
||||||
|
import 'transaction_providers.dart';
|
||||||
|
|
||||||
|
part 'transactions_controller.g.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stream-провайдеры (read-only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Реактивный поток транзакций пользователя.
|
||||||
|
///
|
||||||
|
/// Все параметры фильтрации опциональны; при их отсутствии возвращаются все
|
||||||
|
/// транзакции пользователя, отсортированные по дате (DESC).
|
||||||
|
///
|
||||||
|
/// Пример использования в виджете:
|
||||||
|
/// ```dart
|
||||||
|
/// final txStream = ref.watch(
|
||||||
|
/// transactionsStreamProvider(userId, type: TransactionType.expense),
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
@riverpod
|
||||||
|
Stream<List<Transaction>> transactionsStream(
|
||||||
|
TransactionsStreamRef ref,
|
||||||
|
int userId, {
|
||||||
|
int? accountId,
|
||||||
|
int? categoryId,
|
||||||
|
TransactionType? type,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) =>
|
||||||
|
ref.watch(transactionRepositoryProvider).watchTransactions(
|
||||||
|
userId: userId,
|
||||||
|
accountId: accountId,
|
||||||
|
categoryId: categoryId,
|
||||||
|
type: type,
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Мутации (CRUD)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Контроллер CRUD-операций над транзакциями.
|
||||||
|
///
|
||||||
|
/// Состояние отражает статус последней мутации:
|
||||||
|
/// - [AsyncData] — операция завершена (или не начата);
|
||||||
|
/// - [AsyncLoading] — выполняется;
|
||||||
|
/// - [AsyncError] — ошибка.
|
||||||
|
@riverpod
|
||||||
|
class TransactionsController extends _$TransactionsController {
|
||||||
|
@override
|
||||||
|
AsyncValue<void> build() => const AsyncData(null);
|
||||||
|
|
||||||
|
/// Создаёт новую транзакцию и возвращает сохранённую сущность.
|
||||||
|
///
|
||||||
|
/// [amount] должен быть > 0 (минорные единицы).
|
||||||
|
/// Для перевода ([TransactionType.transfer]) передайте [transferToAccountId].
|
||||||
|
Future<Transaction> createTransaction({
|
||||||
|
required int userId,
|
||||||
|
required int accountId,
|
||||||
|
int? categoryId,
|
||||||
|
required TransactionType type,
|
||||||
|
required int amount,
|
||||||
|
required DateTime date,
|
||||||
|
String? note,
|
||||||
|
int? transferToAccountId,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => ref.read(transactionRepositoryProvider).create(
|
||||||
|
userId: userId,
|
||||||
|
accountId: accountId,
|
||||||
|
categoryId: categoryId,
|
||||||
|
type: type,
|
||||||
|
amount: amount,
|
||||||
|
date: date,
|
||||||
|
note: note,
|
||||||
|
transferToAccountId: transferToAccountId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
state = result.hasError
|
||||||
|
? AsyncError(result.error!, StackTrace.current)
|
||||||
|
: const AsyncData(null);
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обновляет существующую транзакцию и возвращает актуальную сущность.
|
||||||
|
Future<Transaction> updateTransaction(Transaction transaction) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => ref.read(transactionRepositoryProvider).update(transaction),
|
||||||
|
);
|
||||||
|
state = result.hasError
|
||||||
|
? AsyncError(result.error!, StackTrace.current)
|
||||||
|
: const AsyncData(null);
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет транзакцию по [id].
|
||||||
|
Future<void> deleteTransaction(int id) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(
|
||||||
|
() => ref.read(transactionRepositoryProvider).delete(id),
|
||||||
|
).then((_) => const AsyncData(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../domain/entities/transaction.dart';
|
||||||
|
|
||||||
|
/// Маппер: строка Drift → доменная сущность [Transaction].
|
||||||
|
extension TransactionMapper on TransactionsTableData {
|
||||||
|
Transaction toDomain() => Transaction(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
accountId: accountId,
|
||||||
|
categoryId: categoryId,
|
||||||
|
type: type,
|
||||||
|
amount: amount,
|
||||||
|
date: date,
|
||||||
|
note: note,
|
||||||
|
transferToAccountId: transferToAccountId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../core/database/daos/transactions_dao.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../../domain/entities/transaction.dart';
|
||||||
|
import '../../domain/repositories/transaction_repository.dart';
|
||||||
|
import '../mappers/transaction_mapper.dart';
|
||||||
|
|
||||||
|
class TransactionRepositoryImpl implements TransactionRepository {
|
||||||
|
const TransactionRepositoryImpl(this._dao);
|
||||||
|
final TransactionsDao _dao;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Transaction>> watchTransactions({
|
||||||
|
required int userId,
|
||||||
|
int? accountId,
|
||||||
|
int? categoryId,
|
||||||
|
TransactionType? type,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) {
|
||||||
|
final filter = TransactionFilter(
|
||||||
|
userId: userId,
|
||||||
|
accountId: accountId,
|
||||||
|
categoryId: categoryId,
|
||||||
|
type: type,
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
);
|
||||||
|
return _dao
|
||||||
|
.watchTransactions(filter)
|
||||||
|
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Transaction?> findById(int id) async {
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row?.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Transaction> create({
|
||||||
|
required int userId,
|
||||||
|
required int accountId,
|
||||||
|
int? categoryId,
|
||||||
|
required TransactionType type,
|
||||||
|
required int amount,
|
||||||
|
required DateTime date,
|
||||||
|
String? note,
|
||||||
|
int? transferToAccountId,
|
||||||
|
}) async {
|
||||||
|
final newId = await _dao.insertTransaction(
|
||||||
|
TransactionsTableCompanion.insert(
|
||||||
|
userId: userId,
|
||||||
|
accountId: accountId,
|
||||||
|
categoryId: Value(categoryId),
|
||||||
|
type: Value(type),
|
||||||
|
amount: amount,
|
||||||
|
date: date,
|
||||||
|
note: Value(note),
|
||||||
|
transferToAccountId: Value(transferToAccountId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final row = await _dao.findById(newId);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Transaction> update(Transaction transaction) async {
|
||||||
|
await _dao.updateTransaction(
|
||||||
|
TransactionsTableCompanion(
|
||||||
|
id: Value(transaction.id),
|
||||||
|
userId: Value(transaction.userId),
|
||||||
|
accountId: Value(transaction.accountId),
|
||||||
|
categoryId: Value(transaction.categoryId),
|
||||||
|
type: Value(transaction.type),
|
||||||
|
amount: Value(transaction.amount),
|
||||||
|
date: Value(transaction.date),
|
||||||
|
note: Value(transaction.note),
|
||||||
|
transferToAccountId: Value(transaction.transferToAccountId),
|
||||||
|
createdAt: Value(transaction.createdAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final row = await _dao.findById(transaction.id);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await _dao.deleteTransaction(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
|
||||||
|
part 'transaction.freezed.dart';
|
||||||
|
|
||||||
|
/// Доменная сущность финансовой транзакции.
|
||||||
|
///
|
||||||
|
/// [amount] всегда положительное значение в минорных единицах (копейки/центы).
|
||||||
|
/// Знак определяется полем [type]: income/transfer увеличивают баланс,
|
||||||
|
/// expense — уменьшают.
|
||||||
|
///
|
||||||
|
/// Для перевода ([TransactionType.transfer]) заполняется [transferToAccountId].
|
||||||
|
/// [categoryId] опционален: переводы обычно без категории.
|
||||||
|
@freezed
|
||||||
|
abstract class Transaction with _$Transaction {
|
||||||
|
const factory Transaction({
|
||||||
|
required int id,
|
||||||
|
required int userId,
|
||||||
|
required int accountId,
|
||||||
|
int? categoryId,
|
||||||
|
required TransactionType type,
|
||||||
|
|
||||||
|
/// Сумма в минорных единицах (всегда > 0).
|
||||||
|
required int amount,
|
||||||
|
required DateTime date,
|
||||||
|
String? note,
|
||||||
|
|
||||||
|
/// Целевой счёт для переводов ([TransactionType.transfer]).
|
||||||
|
int? transferToAccountId,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) = _Transaction;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../entities/transaction.dart';
|
||||||
|
|
||||||
|
/// Контракт доступа к транзакциям.
|
||||||
|
///
|
||||||
|
/// Слой [data] предоставляет реализацию поверх Drift DAO;
|
||||||
|
/// слой [application] работает только с этим интерфейсом.
|
||||||
|
abstract interface class TransactionRepository {
|
||||||
|
/// Реактивный поток транзакций пользователя с опциональной фильтрацией.
|
||||||
|
///
|
||||||
|
/// Результаты отсортированы по [Transaction.date] в убывающем порядке.
|
||||||
|
Stream<List<Transaction>> watchTransactions({
|
||||||
|
required int userId,
|
||||||
|
int? accountId,
|
||||||
|
int? categoryId,
|
||||||
|
TransactionType? type,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Transaction?> findById(int id);
|
||||||
|
|
||||||
|
Future<Transaction> create({
|
||||||
|
required int userId,
|
||||||
|
required int accountId,
|
||||||
|
int? categoryId,
|
||||||
|
required TransactionType type,
|
||||||
|
|
||||||
|
/// Сумма в минорных единицах (должна быть > 0).
|
||||||
|
required int amount,
|
||||||
|
required DateTime date,
|
||||||
|
String? note,
|
||||||
|
|
||||||
|
/// Целевой счёт для [TransactionType.transfer].
|
||||||
|
int? transferToAccountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Transaction> update(Transaction transaction);
|
||||||
|
|
||||||
|
Future<void> delete(int id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../domain/entities/user.dart';
|
||||||
|
import 'user_providers.dart';
|
||||||
|
|
||||||
|
part 'active_user_controller.g.dart';
|
||||||
|
|
||||||
|
const _kActiveUserKey = 'active_user_id';
|
||||||
|
|
||||||
|
/// Контроллер текущего активного профиля (хранится в app_preferences).
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class ActiveUserController extends _$ActiveUserController {
|
||||||
|
@override
|
||||||
|
Future<User?> build() async {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
final idStr = await db.settingsDao.getPreference(_kActiveUserKey);
|
||||||
|
if (idStr == null) return null;
|
||||||
|
final id = int.tryParse(idStr);
|
||||||
|
if (id == null) return null;
|
||||||
|
return ref.read(userRepositoryProvider).findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setActiveUser(User user) async {
|
||||||
|
final db = ref.read(appDatabaseProvider);
|
||||||
|
await db.settingsDao.setPreference(_kActiveUserKey, user.id.toString());
|
||||||
|
state = AsyncData(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearActiveUser() async {
|
||||||
|
final db = ref.read(appDatabaseProvider);
|
||||||
|
await db.settingsDao.deletePreference(_kActiveUserKey);
|
||||||
|
state = const AsyncData(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../data/repositories/user_repository_impl.dart';
|
||||||
|
import '../domain/repositories/user_repository.dart';
|
||||||
|
|
||||||
|
part 'user_providers.g.dart';
|
||||||
|
|
||||||
|
/// DI-провайдер репозитория пользователей.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
UserRepository userRepository(UserRepositoryRef ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
return UserRepositoryImpl(db.usersDao);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../domain/entities/user.dart';
|
||||||
|
import 'user_providers.dart';
|
||||||
|
|
||||||
|
part 'users_controller.g.dart';
|
||||||
|
|
||||||
|
/// Реактивный список всех пользователей.
|
||||||
|
@riverpod
|
||||||
|
Stream<List<User>> usersStream(UsersStreamRef ref) =>
|
||||||
|
ref.watch(userRepositoryProvider).watchAll();
|
||||||
|
|
||||||
|
/// Контроллер операций над профилями.
|
||||||
|
@riverpod
|
||||||
|
class UsersController extends _$UsersController {
|
||||||
|
@override
|
||||||
|
AsyncValue<void> build() => const AsyncData(null);
|
||||||
|
|
||||||
|
Future<User> createUser(String name) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => ref.read(userRepositoryProvider).create(name),
|
||||||
|
);
|
||||||
|
state = result.hasError ? AsyncError(result.error!, StackTrace.current) : const AsyncData(null);
|
||||||
|
return result.value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> renameUser(int id, String newName) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(
|
||||||
|
() => ref.read(userRepositoryProvider).rename(id, newName),
|
||||||
|
).then((_) => const AsyncData(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteUser(int id) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(
|
||||||
|
() => ref.read(userRepositoryProvider).delete(id),
|
||||||
|
).then((_) => const AsyncData(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../domain/entities/user.dart';
|
||||||
|
|
||||||
|
/// Маппер: Drift row ↔ доменная сущность User.
|
||||||
|
extension UserMapper on UsersTableData {
|
||||||
|
User toDomain() => User(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../core/database/daos/users_dao.dart';
|
||||||
|
import '../../domain/entities/user.dart';
|
||||||
|
import '../../domain/repositories/user_repository.dart';
|
||||||
|
import '../mappers/user_mapper.dart';
|
||||||
|
|
||||||
|
/// Реализация UserRepository поверх Drift UsersDao.
|
||||||
|
class UserRepositoryImpl implements UserRepository {
|
||||||
|
const UserRepositoryImpl(this._dao);
|
||||||
|
|
||||||
|
final UsersDao _dao;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<User>> watchAll() =>
|
||||||
|
_dao.watchAll().map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<User?> findById(int id) async {
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row?.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<User> create(String name) async {
|
||||||
|
final id = await _dao.insertUser(
|
||||||
|
UsersTableCompanion.insert(name: name),
|
||||||
|
);
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<User> rename(int id, String newName) async {
|
||||||
|
await _dao.updateUser(
|
||||||
|
UsersTableCompanion(id: Value(id), name: Value(newName)),
|
||||||
|
);
|
||||||
|
final row = await _dao.findById(id);
|
||||||
|
return row!.toDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(int id) => _dao.deleteUser(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'user.freezed.dart';
|
||||||
|
|
||||||
|
/// Неизменяемая доменная сущность «Пользователь».
|
||||||
|
@freezed
|
||||||
|
abstract class User with _$User {
|
||||||
|
const factory User({
|
||||||
|
required int id,
|
||||||
|
required String name,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) = _User;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import '../entities/user.dart';
|
||||||
|
|
||||||
|
/// Контракт репозитория пользователей.
|
||||||
|
/// Зависит только от чистого Dart — без Drift, без Flutter.
|
||||||
|
abstract interface class UserRepository {
|
||||||
|
/// Реактивный поток всех профилей.
|
||||||
|
Stream<List<User>> watchAll();
|
||||||
|
|
||||||
|
/// Найти пользователя по id. null если не найден.
|
||||||
|
Future<User?> findById(int id);
|
||||||
|
|
||||||
|
/// Создать новый профиль. Возвращает созданную сущность.
|
||||||
|
Future<User> create(String name);
|
||||||
|
|
||||||
|
/// Переименовать профиль.
|
||||||
|
Future<User> rename(int id, String newName);
|
||||||
|
|
||||||
|
/// Удалить профиль (каскадно удаляет все связанные данные).
|
||||||
|
Future<void> delete(int id);
|
||||||
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.19"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.18.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.11"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.2.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.12.0 <4.0.0"
|
||||||
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: new_budget
|
||||||
|
description: "Personal finance tracking app with multi-profile support."
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.12.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# State management
|
||||||
|
flutter_riverpod: ^2.6.1
|
||||||
|
riverpod_annotation: ^2.6.1
|
||||||
|
|
||||||
|
# Database
|
||||||
|
drift: ^2.26.1
|
||||||
|
drift_flutter: ^0.2.4
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
go_router: ^14.8.1
|
||||||
|
|
||||||
|
# Immutable entities
|
||||||
|
freezed_annotation: ^2.4.4
|
||||||
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
intl: ^0.20.2
|
||||||
|
|
||||||
|
# Icons
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# Lints
|
||||||
|
flutter_lints: ^5.0.0
|
||||||
|
|
||||||
|
# Code generation
|
||||||
|
build_runner: ^2.4.15
|
||||||
|
riverpod_generator: ^2.6.5
|
||||||
|
riverpod_lint: ^2.6.5
|
||||||
|
custom_lint: ^0.7.5
|
||||||
|
drift_dev: ^2.26.1
|
||||||
|
freezed: ^2.5.7
|
||||||
|
json_serializable: ^6.9.5
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// This is a basic Flutter widget test.
|
||||||
|
//
|
||||||
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
|
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||||
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:new_budget/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
|
// Build our app and trigger a frame.
|
||||||
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
|
// Verify that our counter starts at 0.
|
||||||
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
expect(find.text('1'), findsNothing);
|
||||||
|
|
||||||
|
// Tap the '+' icon and trigger a frame.
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify that our counter has incremented.
|
||||||
|
expect(find.text('0'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user