This commit is contained in:
2026-05-27 09:19:19 +03:00
commit 9870626023
82 changed files with 5696 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"PowerShell(flutter *)"
]
}
}
+45
View File
@@ -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
+30
View File
@@ -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'
+165
View File
@@ -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 парные транзакции.
+17
View File
@@ -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.
+15
View File
@@ -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
+14
View File
@@ -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
+45
View File
@@ -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>
+45
View File
@@ -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>
+24
View File
@@ -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)
}
+6
View File
@@ -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
+5
View File
@@ -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
+26
View File
@@ -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")
+214
View File
@@ -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,
});
+334
View File
@@ -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,
});
+966
View File
@@ -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 });
+172
View File
@@ -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>
+530
View File
@@ -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,
});
+743
View File
@@ -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
View File
@@ -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),
),
);
}
}
+27
View File
@@ -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';
}
+56
View File
@@ -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)
}
+32
View File
@@ -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)();
}
+29
View File
@@ -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);
}
+67
View File
@@ -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
View File
@@ -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"
+51
View File
@@ -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
+30
View File
@@ -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);
});
}