commit 9870626023f356d037965ad544643fc4893128f8 Author: Sanders Date: Wed May 27 09:19:19 2026 +0300 Init diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..26a4ee6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "PowerShell(flutter *)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..cda28f3 --- /dev/null +++ b/.metadata @@ -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' diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d215c86 --- /dev/null +++ b/PLAN.md @@ -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 парные транзакции. diff --git a/README.md b/README.md new file mode 100644 index 0000000..012e344 --- /dev/null +++ b/README.md @@ -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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..55dfdd0 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..df8fa7c --- /dev/null +++ b/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e65c06c --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/sanders/budget/new_budget/MainActivity.kt b/android/app/src/main/kotlin/com/sanders/budget/new_budget/MainActivity.kt new file mode 100644 index 0000000..49b04a1 --- /dev/null +++ b/android/app/src/main/kotlin/com/sanders/budget/new_budget/MainActivity.kt @@ -0,0 +1,5 @@ +package com.sanders.budget.new_budget + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/design/android-frame.jsx b/design/android-frame.jsx new file mode 100644 index 0000000..2dc0eb8 --- /dev/null +++ b/design/android-frame.jsx @@ -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 ( +
+ {/* time left */} +
+ 9:30 +
+ {/* camera punch-hole (center) */} +
+ {/* status icons right */} +
+
+ + + + + + +
+ + + + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Top app bar (Material 3 small/medium) +// ───────────────────────────────────────────────────────────── +function AndroidAppBar({ title = 'Title', large = false }) { + const iconDot = ( +
+
+
+ ); + return ( +
+
+ {iconDot} + {!large && ( + {title} + )} + {large &&
} + {iconDot} +
+ {large && ( +
{title}
+ )} +
+ ); +} + +// ───────────────────────────────────────────────────────────── +// List item (Material 3) +// ───────────────────────────────────────────────────────────── +function AndroidListItem({ headline, supporting, leading }) { + return ( +
+ {leading && ( +
{leading}
+ )} +
+
{headline}
+ {supporting && ( +
{supporting}
+ )} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Gesture nav bar (pill) +// ───────────────────────────────────────────────────────────── +function AndroidNavBar({ dark = false }) { + return ( +
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Device frame — wraps everything +// ───────────────────────────────────────────────────────────── +function AndroidDevice({ + children, width = 412, height = 892, dark = false, + title, large = false, keyboard = false, +}) { + return ( +
+ + {title !== undefined && } +
+ {children} +
+ {keyboard && } + +
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Keyboard — Gboard (Material 3) +// ───────────────────────────────────────────────────────────── +function AndroidKeyboard() { + let _k = 0; + const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => ( +
{l}
+ ); + const row = (keys, style = {}) => ( +
+ {keys.map(l => key(l))} +
+ ); + return ( +
+ {/* navbar spacer (icons omitted) */} +
+ {/* key rows */} +
+ {row(['q','w','e','r','t','y','u','i','o','p'])} + {row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })} +
+ {key('', { bg: MD_C.surfaceVariant })} +
+ {['z','x','c','v','b','n','m'].map(l => key(l))} +
+ {key('', { bg: MD_C.surfaceVariant })} +
+
+ {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 })} +
+
+
+ ); +} + +Object.assign(window, { + AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard, +}); diff --git a/design/common.jsx b/design/common.jsx new file mode 100644 index 0000000..3e29eac --- /dev/null +++ b/design/common.jsx @@ -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 }) => ( + + {d} + +); + +const Icons = { + search: } />, + bell: } />, + menu: } />, + chev: } />, + chevRt: } size={16} />, + plus: } stroke={2} />, + filter: } />, + home: } />, + stats: } />, + wallet: } />, + user: } />, + card: } />, + cash: } />, + bank: } />, + pig: } />, + food: } />, + cart: } />, + car: } />, + house: } />, + film: } />, + health: } />, + gift: } />, + more: } />, + arrowUp: } size={14} />, + arrowDn: } size={14} />, + eye: } />, +}; + +// ─── Caveat annotation tag (handdrawn comment) ───────────────────── +function Note({ children, style }) { + return ( + {children} + ); +} + +// Small arrow used with Note callouts +function NoteArrow({ rot = 0, len = 28, style }) { + return ( + + + + + ); +} + +// ─── 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 ( + + {arcs.map(a => ( + onSegment && onSegment(a.i)} + style={{ cursor: onSegment ? 'pointer' : 'default', transition: 'opacity .2s' }} /> + ))} + + ); +} + +// 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 ( +
+ {data.map((d, i) => { + const w = (d.value / total) * 100; + const el = ( +
+ ); + acc += w; + return el; + })} +
+ ); +} + +// ─── 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 ( +
+
{cat.icon}
+
+
{tx.merchant}
+
+ {cat.label}·{tx.when} +
+
+
0 ? 'var(--pos)' : 'var(--ink)', + fontWeight: 500, + }}> + {tx.amount > 0 ? '+' : '−'}{fmtNoCur(tx.amount)} ₽ +
+
+ ); +} + +// ─── Day group header ────────────────────────────────────────────── +function DayHeader({ label, total }) { + return ( +
+ {label} + −{fmtNoCur(total)} ₽ +
+ ); +} + +// ─── 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 ( +
+ {items.map((it, i) => ( +
+
{it.icon}
+ {it.label} +
+ ))} +
+ ); +} + +// ─── FAB ─────────────────────────────────────────────────────────── +function FAB({ bottom = 70, right = 16 }) { + return ( +
{Icons.plus}
+ ); +} + +// ─── Wireframe app bar (compact, neutral) ────────────────────────── +function WfBar({ title, sub, right }) { + return ( +
+
+ {sub && ( +
+ {sub} +
+ )} +
+ {title} +
+
+
+ {right} +
+
+ ); +} + +// ─── Variation label (printed below frame on canvas) ──────────────── +function VLabel({ n, title, axes }) { + return ( +
+
+ 0{n} + {title} +
+
+ {axes.map((a, i) => ( + {a} + ))} +
+
+ ); +} + +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, +}); diff --git a/design/design-canvas.jsx b/design/design-canvas.jsx new file mode 100644 index 0000000..fa1f93e --- /dev/null +++ b/design/design-canvas.jsx @@ -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: +// +// +// +// +// +// + +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 ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + 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}`)} /> + ))} +
+ {rest} +
+ ); +} + +// 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 / / +// 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 = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -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 = '' + + (fontCss ? '' : '') + xml + ''; + 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 ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 }) => ( + + ); + + // 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( +
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) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/design/index.html b/design/index.html new file mode 100644 index 0000000..fb8a0aa --- /dev/null +++ b/design/index.html @@ -0,0 +1,172 @@ + + + + + +Главный экран — Бюджет · вайрфреймы + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/design/tweaks-panel.jsx b/design/tweaks-panel.jsx new file mode 100644 index 0000000..bed5d66 --- /dev/null +++ b/design/tweaks-panel.jsx @@ -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 , 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 ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('palette', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +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,"); + 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 ( + <> + +
+
+ {title} + +
+
+ {children} +
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +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) { + // onChange(e.target.value)}> + {options.map((o) => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return ; + })} + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +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 ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +// 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 }) => ( + +); + +// 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 ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); + } + // Native 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 ( + +
+ {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 ( + + ); + })} +
+
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/design/variants.jsx b/design/variants.jsx new file mode 100644 index 0000000..5ac9f30 --- /dev/null +++ b/design/variants.jsx @@ -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 ( +
+ {label} + {Icons.chev} +
+ ); +} + +function KPI({ label, value, hint, accent, mono = true }) { + return ( +
+
{label}
+
{value}
+ {hint && ( +
{hint}
+ )} +
+ ); +} + +function CategoryChip({ cat, active, count, onClick, dense }) { + return ( +
+ {cat?.color && ( +
+ )} + {cat ? cat.label : 'Все'} + {count !== undefined && ( + · {count} + )} +
+ ); +} + +// Horizontal scroll strip +function HScroll({ children, gap = 8, pad = 16 }) { + return ( +
{children}
+ ); +} + +// ===================================================================== +// 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 ( +
+ {/* Header */} +
+
+
Бюджет
+
Май 2026
+
+
+ {Icons.search}{Icons.bell} +
+
+ + {/* Account tabs (segmented pills, scrollable) */} + + {ACCOUNTS.map(a => ( +
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, + }}> + {a.icon} + {a.short} +
+ ))} +
+ + {/* Month KPI strip — balance + доходы / расходы only */} +
+
+ Баланс + 184 320 ₽ +
+
+
+
Доходы
+
+95 000 ₽
+
+
+
+
Расходы
+
−67 200 ₽
+
+
+
+ + {/* Donut + legend */} +
+
+ { + const id = DONUT_DATA[i].id; + setFilterCat(filterCat === id ? null : id); + setActive(active === i ? null : i); + }} /> +
+
Расходы
+
67 200 ₽
+
+
+
+ {DONUT_DATA.slice(0, 4).map((d, i) => { + const pct = Math.round((d.value / SPEND_TOTAL) * 100); + return ( +
+
+ {d.label} + {pct}% +
+ ); + })} +
+ ещё 2 категории →
+
+
+ + {/* Section header */} +
+
Транзакции
+ {visibleTx.length} операций +
+ + {/* Bottomsheet-trigger filter (from V2) */} +
{ 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', + }}> + {Icons.filter} + + {activeCat ? ( + <> +
+ {activeCat.label} + + ) : ( + Все категории · все типы + )} + + {visibleTx.length} + {Icons.chev} +
+ + {/* TX list grouped by day */} +
+ {groups.map(g => ( + + + {g.items.map(tx => )} + + ))} +
+ + +
+ ); +} + +// ===================================================================== +// V2 — Account dropdown + hero card + bottomsheet filter +// ===================================================================== +function V2() { + const [acc] = React.useState('card'); + const accObj = ACCOUNTS.find(a => a.id === acc); + + return ( +
+ {/* Account dropdown header */} +
+
+
{accObj.icon}
+
+ {accObj.label} + 4 счёта · переключить +
+ {Icons.chev} +
+
+ {Icons.search}{Icons.bell} +
+
+ + {/* Hero balance card — editorial */} +
+
Баланс счёта
+
142 500 ₽
+ + {/* Inline sparkline */} + + + + + +
+
+
Доход
+
+95 000
+
+
+
Расход
+
−67 200
+
+
+
Остаток
+
27 800
+
+
+
+ + {/* Donut centered, with big center stat */} +
+
Расходы по категориям
+ Май +
+
+
+ +
+
Всего
+
67 200 ₽
+
6 категорий
+
+
+
+ + {/* Filter row (sheet trigger) */} +
+ {Icons.filter} + Все категории · все типы + 132 + {Icons.chev} +
+ + {/* Compact TX list */} +
+ {TX.slice(0, 5).map(tx => )} +
+ + {/* Sheet peek note */} +
+ + Тап — открывает bottom sheet с фильтрами +
+ + +
+ ); +} + +// ===================================================================== +// V3 — Swipeable account cards carousel + stacked bar + chips +// ===================================================================== +function V3() { + const [filterCat, setFilterCat] = React.useState(null); + + return ( +
+ {/* Title */} +
+
+
Привет, Аня
+
Май 2026 · 27 800 ₽ свободно
+
+
{Icons.bell}
+
+ + {/* Account cards carousel (peek style) */} +
+ + {ACCOUNTS.map((a, i) => ( +
+
+
{a.icon}
+ {a.id === 'all' ? 'сводно' : 'счёт'} +
+
{a.label}
+
{fmtNoCur(a.balance)} ₽
+
+ ))} +
+ {/* Page dots */} +
+ {ACCOUNTS.map((_, i) => ( +
+ ))} +
+
+ + {/* 2x2 KPI */} +
+ + + + +
+ + {/* Stacked bar — alt visualization */} +
+
+
Структура расходов
+ 67 200 ₽ +
+ +
+ {DONUT_DATA.map(d => { + const pct = Math.round((d.value / SPEND_TOTAL) * 100); + return ( +
+
+ {d.label} + {pct}% +
+ ); + })} +
+
+ + {/* Inline category chips */} +
+
Последние операции
+ Все → +
+ + setFilterCat(null)} dense /> + {CATS.slice(0, 5).map(c => ( + setFilterCat(filterCat === c.id ? null : c.id)} /> + ))} + + +
+ {TX.slice(0, 4).map(tx => )} +
+ + +
+ ); +} + +// ===================================================================== +// 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 ( +
+ {/* Top: editorial title + tiny tabs */} +
+
+
2026
+
Май.
+
+
+ {Icons.eye}{Icons.bell} +
+
+ + {/* Tiny segmented tabs */} +
+
+ {ACCOUNTS.map(a => ( +
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}
+ ))} +
+
+ + {/* Number-first month summary */} +
+
+
Баланс
+
184 320 ₽
+
+
+ {Icons.arrowUp} + 95 000 ₽ + доход +
+
+ {Icons.arrowDn} + 67 200 ₽ + расход +
+
+ = + 27 800 ₽ + остаток +
+
+
+ {/* Donut with big center number */} +
+ +
+
−67.2K
+
траты
+
+
+
+ + {/* Divider */} +
+ + {/* Filter chips */} +
+
Операции
+ 132 в мае +
+ + setFilterCat(null)} dense /> + {CATS.map(c => ( + setFilterCat(filterCat === c.id ? null : c.id)} /> + ))} + + +
+ + {TX.slice(0, 3).map(tx => )} + + {TX.slice(3, 5).map(tx => )} +
+ + +
+ ); +} + +// ===================================================================== +// 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 ( +
+
+
+
Бюджет · Май 2026
+
Обзор
+
+
{Icons.search}{Icons.bell}
+
+ + {/* Mini account grid 2x2 */} +
+ {ACCOUNTS.map(a => { + const isActive = a.id === acc; + return ( +
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', + }}> +
+ {a.icon} + {a.label} + {isActive && ( +
+ )} +
+
{fmtNoCur(a.balance)} ₽
+
+ ); + })} +
+ + {/* 2x2 KPI grid */} +
+ {[ + { 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) => ( +
+
{k.l}
+
{k.v}
+
+ ))} +
+ + {/* Donut + chip-legend */} +
+
+ d.id === filterCat) : null} + onSegment={i => setFilterCat(filterCat === DONUT_DATA[i].id ? null : DONUT_DATA[i].id)} /> +
+
67.2K
+
Расходы
+
+
+
+ {DONUT_DATA.map(d => { + const active = filterCat === d.id; + return ( +
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', + }}> +
+ {d.label} +
+ ); + })} +
+
+ + {/* Filter + list */} +
+
+ Операции {filterCat && · {CATS.find(c => c.id === filterCat)?.label}} +
+ {filterCat && ( + setFilterCat(null)} style={{ fontSize: 11, color: 'var(--accent)', cursor: 'pointer' }}>Сбросить + )} +
+
+ {(filterCat ? TX.filter(t => t.cat === filterCat) : TX).slice(0, 4).map(tx => )} +
+ + +
+ ); +} + +Object.assign(window, { V1, V2, V3, V4, V5, CategoryChip, KPI, MonthChip, HScroll }); diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..244a702 --- /dev/null +++ b/lib/main.dart @@ -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 createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + 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), + ), + ); + } +} diff --git a/lib/src/core/constants/app_constants.dart b/lib/src/core/constants/app_constants.dart new file mode 100644 index 0000000..c3bd910 --- /dev/null +++ b/lib/src/core/constants/app_constants.dart @@ -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'; +} diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart new file mode 100644 index 0000000..56ca3f6 --- /dev/null +++ b/lib/src/core/database/app_database.dart @@ -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'); + } +} diff --git a/lib/src/core/database/converters/enum_converters.dart b/lib/src/core/database/converters/enum_converters.dart new file mode 100644 index 0000000..9247315 --- /dev/null +++ b/lib/src/core/database/converters/enum_converters.dart @@ -0,0 +1,65 @@ +import 'package:drift/drift.dart'; + +// --------------------------------------------------------------------------- +// AccountType +// --------------------------------------------------------------------------- +enum AccountType { cash, card, bank, savings } + +class AccountTypeConverter extends TypeConverter { + 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 { + 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 { + 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 { + const AppThemeModeConverter(); + + @override + AppThemeMode fromSql(String fromDb) => + AppThemeMode.values.firstWhere((e) => e.name == fromDb); + + @override + String toSql(AppThemeMode value) => value.name; +} diff --git a/lib/src/core/database/daos/accounts_dao.dart b/lib/src/core/database/daos/accounts_dao.dart new file mode 100644 index 0000000..e313d5a --- /dev/null +++ b/lib/src/core/database/daos/accounts_dao.dart @@ -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 + with _$AccountsDaoMixin { + AccountsDao(super.db); + + /// Реактивный поток счетов пользователя (не архивных). + Stream> watchAccountsByUser(int userId) => + (select(accountsTable) + ..where((t) => t.userId.equals(userId) & t.archived.equals(false)) + ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) + .watch(); + + Future> getAccountsByUser(int userId) => + (select(accountsTable) + ..where((t) => t.userId.equals(userId) & t.archived.equals(false))) + .get(); + + Future findById(int id) => + (select(accountsTable)..where((t) => t.id.equals(id))).getSingleOrNull(); + + Future insertAccount(AccountsTableCompanion companion) => + into(accountsTable).insert(companion); + + Future updateAccount(AccountsTableCompanion companion) => + update(accountsTable).replace(companion); + + Future archiveAccount(int id) => (update(accountsTable) + ..where((t) => t.id.equals(id))) + .write(const AccountsTableCompanion(archived: Value(true))); + + /// Реактивный текущий баланс счёта (начальный + сумма транзакций). + /// TODO: добавить сложную SQL-агрегацию с учётом типа транзакции. + Stream watchAccountBalance(int accountId) { + // Stub: возвращает только initialBalance пока не реализована агрегация. + return (select(accountsTable)..where((t) => t.id.equals(accountId))) + .watchSingleOrNull() + .map((a) => a?.initialBalance ?? 0); + } +} diff --git a/lib/src/core/database/daos/categories_dao.dart b/lib/src/core/database/daos/categories_dao.dart new file mode 100644 index 0000000..36cfb52 --- /dev/null +++ b/lib/src/core/database/daos/categories_dao.dart @@ -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 + with _$CategoriesDaoMixin { + CategoriesDao(super.db); + + Stream> watchCategoriesByUser(int userId) => + (select(categoriesTable) + ..where( + (t) => t.userId.equals(userId) & t.archived.equals(false)) + ..orderBy([(t) => OrderingTerm.asc(t.name)])) + .watch(); + + Stream> watchByType( + int userId, + CategoryType type, + ) => + (select(categoriesTable) + ..where((t) => + t.userId.equals(userId) & + t.type.equalsValue(type) & + t.archived.equals(false))) + .watch(); + + Future> getCategoriesByUser(int userId) => + (select(categoriesTable) + ..where( + (t) => t.userId.equals(userId) & t.archived.equals(false))) + .get(); + + Future findById(int id) => + (select(categoriesTable)..where((t) => t.id.equals(id))) + .getSingleOrNull(); + + Future insertCategory(CategoriesTableCompanion companion) => + into(categoriesTable).insert(companion); + + Future updateCategory(CategoriesTableCompanion companion) => + update(categoriesTable).replace(companion); + + Future archiveCategory(int id) => + (update(categoriesTable)..where((t) => t.id.equals(id))) + .write(const CategoriesTableCompanion(archived: Value(true))); +} diff --git a/lib/src/core/database/daos/settings_dao.dart b/lib/src/core/database/daos/settings_dao.dart new file mode 100644 index 0000000..c76b17d --- /dev/null +++ b/lib/src/core/database/daos/settings_dao.dart @@ -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 + with _$SettingsDaoMixin { + SettingsDao(super.db); + + // ── Settings per user ────────────────────────────────────────────────────── + + Stream watchSettingsByUser(int userId) => + (select(settingsTable)..where((t) => t.userId.equals(userId))) + .watchSingleOrNull(); + + Future getSettingsByUser(int userId) => + (select(settingsTable)..where((t) => t.userId.equals(userId))) + .getSingleOrNull(); + + Future upsertSettings(SettingsTableCompanion companion) => + into(settingsTable).insertOnConflictUpdate(companion); + + // ── App preferences (key-value) ──────────────────────────────────────────── + + Future getPreference(String key) async { + final row = await (select(appPreferencesTable) + ..where((t) => t.key.equals(key))) + .getSingleOrNull(); + return row?.value; + } + + Future setPreference(String key, String value) => + into(appPreferencesTable).insertOnConflictUpdate( + AppPreferencesTableCompanion( + key: Value(key), + value: Value(value), + ), + ); + + Future deletePreference(String key) => + (delete(appPreferencesTable)..where((t) => t.key.equals(key))).go(); +} diff --git a/lib/src/core/database/daos/transactions_dao.dart b/lib/src/core/database/daos/transactions_dao.dart new file mode 100644 index 0000000..2a9c3bb --- /dev/null +++ b/lib/src/core/database/daos/transactions_dao.dart @@ -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 + with _$TransactionsDaoMixin { + TransactionsDao(super.db); + + /// Реактивный поток транзакций с фильтром. + Stream> 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> getTransactions( + TransactionFilter filter) => + watchTransactions(filter).first; + + Future findById(int id) => + (select(transactionsTable)..where((t) => t.id.equals(id))) + .getSingleOrNull(); + + Future insertTransaction(TransactionsTableCompanion companion) => + into(transactionsTable).insert(companion); + + Future updateTransaction(TransactionsTableCompanion companion) => + update(transactionsTable).replace(companion); + + Future deleteTransaction(int id) => + (delete(transactionsTable)..where((t) => t.id.equals(id))).go(); + + /// TODO: сложные SQL-агрегаты: суммы по категориям за период. + /// Stream> watchTotalsByCategory(int userId, DateTime from, DateTime to) +} diff --git a/lib/src/core/database/daos/users_dao.dart b/lib/src/core/database/daos/users_dao.dart new file mode 100644 index 0000000..1ab7c5a --- /dev/null +++ b/lib/src/core/database/daos/users_dao.dart @@ -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 with _$UsersDaoMixin { + UsersDao(super.db); + + /// Реактивный поток всех пользователей. + Stream> watchAll() => select(usersTable).watch(); + + /// Единоразовый запрос всех пользователей. + Future> getAll() => select(usersTable).get(); + + /// Найти пользователя по id. + Future findById(int id) => + (select(usersTable)..where((t) => t.id.equals(id))).getSingleOrNull(); + + /// Создать пользователя. Возвращает id. + Future insertUser(UsersTableCompanion companion) => + into(usersTable).insert(companion); + + /// Обновить пользователя. + Future updateUser(UsersTableCompanion companion) => + update(usersTable).replace(companion); + + /// Удалить пользователя. + Future deleteUser(int id) => + (delete(usersTable)..where((t) => t.id.equals(id))).go(); +} diff --git a/lib/src/core/database/tables/accounts_table.dart b/lib/src/core/database/tables/accounts_table.dart new file mode 100644 index 0000000..9d7bf84 --- /dev/null +++ b/lib/src/core/database/tables/accounts_table.dart @@ -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)(); +} diff --git a/lib/src/core/database/tables/categories_table.dart b/lib/src/core/database/tables/categories_table.dart new file mode 100644 index 0000000..d72d285 --- /dev/null +++ b/lib/src/core/database/tables/categories_table.dart @@ -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))(); +} diff --git a/lib/src/core/database/tables/settings_table.dart b/lib/src/core/database/tables/settings_table.dart new file mode 100644 index 0000000..d6c7b2d --- /dev/null +++ b/lib/src/core/database/tables/settings_table.dart @@ -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 get primaryKey => {userId}; +} + +/// Глобальные предпочтения приложения (напр. активный userId). +class AppPreferencesTable extends Table { + @override + String get tableName => 'app_preferences'; + + TextColumn get key => text()(); + TextColumn get value => text()(); + + @override + Set get primaryKey => {key}; +} diff --git a/lib/src/core/database/tables/transactions_table.dart b/lib/src/core/database/tables/transactions_table.dart new file mode 100644 index 0000000..2da3436 --- /dev/null +++ b/lib/src/core/database/tables/transactions_table.dart @@ -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)(); +} diff --git a/lib/src/core/database/tables/users_table.dart b/lib/src/core/database/tables/users_table.dart new file mode 100644 index 0000000..fd6ba0c --- /dev/null +++ b/lib/src/core/database/tables/users_table.dart @@ -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)(); +} diff --git a/lib/src/core/errors/failures.dart b/lib/src/core/errors/failures.dart new file mode 100644 index 0000000..5fac5ee --- /dev/null +++ b/lib/src/core/errors/failures.dart @@ -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); +} diff --git a/lib/src/core/money/money.dart b/lib/src/core/money/money.dart new file mode 100644 index 0000000..46e32cb --- /dev/null +++ b/lib/src/core/money/money.dart @@ -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))}'; +} diff --git a/lib/src/core/providers/database_provider.dart b/lib/src/core/providers/database_provider.dart new file mode 100644 index 0000000..1a97ca7 --- /dev/null +++ b/lib/src/core/providers/database_provider.dart @@ -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; +} diff --git a/lib/src/features/accounts/application/account_providers.dart b/lib/src/features/accounts/application/account_providers.dart new file mode 100644 index 0000000..27d22e9 --- /dev/null +++ b/lib/src/features/accounts/application/account_providers.dart @@ -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); +} diff --git a/lib/src/features/accounts/application/accounts_controller.dart b/lib/src/features/accounts/application/accounts_controller.dart new file mode 100644 index 0000000..46eabeb --- /dev/null +++ b/lib/src/features/accounts/application/accounts_controller.dart @@ -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> accountsStream(AccountsStreamRef ref, int userId) => + ref.watch(accountRepositoryProvider).watchByUser(userId); + +/// Текущий баланс счёта. +@riverpod +Stream accountBalance(AccountBalanceRef ref, int accountId) => + ref.watch(accountRepositoryProvider).watchBalance(accountId); + +/// Контроллер CRUD-операций над счетами. +@riverpod +class AccountsController extends _$AccountsController { + @override + AsyncValue build() => const AsyncData(null); + + Future 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 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 archiveAccount(int id) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(accountRepositoryProvider).archive(id), + ).then((_) => const AsyncData(null)); + } +} diff --git a/lib/src/features/accounts/data/mappers/account_mapper.dart b/lib/src/features/accounts/data/mappers/account_mapper.dart new file mode 100644 index 0000000..1b9a339 --- /dev/null +++ b/lib/src/features/accounts/data/mappers/account_mapper.dart @@ -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, + ); +} diff --git a/lib/src/features/accounts/data/repositories/account_repository_impl.dart b/lib/src/features/accounts/data/repositories/account_repository_impl.dart new file mode 100644 index 0000000..9ee8a20 --- /dev/null +++ b/lib/src/features/accounts/data/repositories/account_repository_impl.dart @@ -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> watchByUser(int userId) => + _dao.watchAccountsByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Future findById(int id) async { + final row = await _dao.findById(id); + return row?.toDomain(); + } + + @override + Future 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 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 archive(int id) => _dao.archiveAccount(id); + + @override + Stream watchBalance(int accountId) => _dao.watchAccountBalance(accountId); +} diff --git a/lib/src/features/accounts/domain/entities/account.dart b/lib/src/features/accounts/domain/entities/account.dart new file mode 100644 index 0000000..4186547 --- /dev/null +++ b/lib/src/features/accounts/domain/entities/account.dart @@ -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; +} diff --git a/lib/src/features/accounts/domain/repositories/account_repository.dart b/lib/src/features/accounts/domain/repositories/account_repository.dart new file mode 100644 index 0000000..321f793 --- /dev/null +++ b/lib/src/features/accounts/domain/repositories/account_repository.dart @@ -0,0 +1,21 @@ +import '../entities/account.dart'; +import '../../../../core/database/converters/enum_converters.dart'; + +abstract interface class AccountRepository { + Stream> watchByUser(int userId); + Future findById(int id); + Future create({ + required int userId, + required String name, + required AccountType type, + required String currency, + int initialBalance = 0, + int? iconCode, + int? colorValue, + }); + Future update(Account account); + Future archive(int id); + + /// Текущий баланс счёта (начальный + агрегат транзакций). + Stream watchBalance(int accountId); +} diff --git a/lib/src/features/categories/application/categories_controller.dart b/lib/src/features/categories/application/categories_controller.dart new file mode 100644 index 0000000..fe58bf3 --- /dev/null +++ b/lib/src/features/categories/application/categories_controller.dart @@ -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> categoriesStream(CategoriesStreamRef ref, int userId) => + ref.watch(categoryRepositoryProvider).watchByUser(userId); + +/// Реактивный список категорий, фильтрованный по типу (income / expense). +@riverpod +Stream> categoriesByTypeStream( + CategoriesByTypeStreamRef ref, + int userId, + CategoryType type, +) => + ref.watch(categoryRepositoryProvider).watchByType(userId, type); + +/// Контроллер CRUD-операций над категориями. +@riverpod +class CategoriesController extends _$CategoriesController { + @override + AsyncValue build() => const AsyncData(null); + + Future 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 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 archiveCategory(int id) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(categoryRepositoryProvider).archive(id), + ).then((_) => const AsyncData(null)); + } +} diff --git a/lib/src/features/categories/application/category_providers.dart b/lib/src/features/categories/application/category_providers.dart new file mode 100644 index 0000000..8d4c40f --- /dev/null +++ b/lib/src/features/categories/application/category_providers.dart @@ -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); +} diff --git a/lib/src/features/categories/data/mappers/category_mapper.dart b/lib/src/features/categories/data/mappers/category_mapper.dart new file mode 100644 index 0000000..c1fc30a --- /dev/null +++ b/lib/src/features/categories/data/mappers/category_mapper.dart @@ -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, + ); +} diff --git a/lib/src/features/categories/data/repositories/category_repository_impl.dart b/lib/src/features/categories/data/repositories/category_repository_impl.dart new file mode 100644 index 0000000..38e8c12 --- /dev/null +++ b/lib/src/features/categories/data/repositories/category_repository_impl.dart @@ -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> watchByUser(int userId) => + _dao.watchCategoriesByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Stream> watchByType(int userId, CategoryType type) => + _dao.watchByType(userId, type).map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Future findById(int id) async { + final row = await _dao.findById(id); + return row?.toDomain(); + } + + @override + Future 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 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 archive(int id) => _dao.archiveCategory(id); +} diff --git a/lib/src/features/categories/domain/entities/category.dart b/lib/src/features/categories/domain/entities/category.dart new file mode 100644 index 0000000..d74be70 --- /dev/null +++ b/lib/src/features/categories/domain/entities/category.dart @@ -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; +} diff --git a/lib/src/features/categories/domain/repositories/category_repository.dart b/lib/src/features/categories/domain/repositories/category_repository.dart new file mode 100644 index 0000000..bcbe43c --- /dev/null +++ b/lib/src/features/categories/domain/repositories/category_repository.dart @@ -0,0 +1,18 @@ +import '../../../../core/database/converters/enum_converters.dart'; +import '../entities/category.dart'; + +abstract interface class CategoryRepository { + Stream> watchByUser(int userId); + Stream> watchByType(int userId, CategoryType type); + Future findById(int id); + Future create({ + required int userId, + required String name, + required CategoryType type, + int? iconCode, + int? colorValue, + int? parentId, + }); + Future update(Category category); + Future archive(int id); +} diff --git a/lib/src/features/settings/application/settings_controller.dart b/lib/src/features/settings/application/settings_controller.dart new file mode 100644 index 0000000..f195038 --- /dev/null +++ b/lib/src/features/settings/application/settings_controller.dart @@ -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 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 setBaseCurrency(String currency) => _update( + (repo) => repo.updateBaseCurrency(userId, currency), + ); + + Future setThemeMode(AppThemeMode themeMode) => _update( + (repo) => repo.updateThemeMode(userId, themeMode), + ); + + Future setLocale(String locale) => _update( + (repo) => repo.updateLocale(userId, locale), + ); + + Future setFirstDayOfMonth(int day) => _update( + (repo) => repo.updateFirstDayOfMonth(userId, day), + ); + + /// Полная перезапись настроек. + Future saveSettings(Settings settings) => _update( + (repo) => repo.upsertSettings(settings), + ); + + // ── Helpers ─────────────────────────────────────────────────────────────── + + Future _update( + Future 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); + }); + } +} diff --git a/lib/src/features/settings/application/settings_providers.dart b/lib/src/features/settings/application/settings_providers.dart new file mode 100644 index 0000000..788ff9a --- /dev/null +++ b/lib/src/features/settings/application/settings_providers.dart @@ -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 settingsStream(SettingsStreamRef ref, int userId) => + ref.watch(settingsRepositoryProvider).watchSettings(userId); diff --git a/lib/src/features/settings/data/mappers/settings_mapper.dart b/lib/src/features/settings/data/mappers/settings_mapper.dart new file mode 100644 index 0000000..4aa88d5 --- /dev/null +++ b/lib/src/features/settings/data/mappers/settings_mapper.dart @@ -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, + ); +} diff --git a/lib/src/features/settings/data/repositories/settings_repository_impl.dart b/lib/src/features/settings/data/repositories/settings_repository_impl.dart new file mode 100644 index 0000000..3034a01 --- /dev/null +++ b/lib/src/features/settings/data/repositories/settings_repository_impl.dart @@ -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 watchSettings(int userId) => + _dao.watchSettingsByUser(userId).map((row) => row?.toDomain()); + + @override + Future getSettings(int userId) async { + final row = await _dao.getSettingsByUser(userId); + return row?.toDomain(); + } + + // ── Запись ──────────────────────────────────────────────────────────────── + + @override + Future upsertSettings(Settings settings) => + _dao.upsertSettings(_toCompanion(settings)); + + @override + Future 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 updateBaseCurrency(int userId, String currency) async { + final current = await _requireSettings(userId); + await upsertSettings(current.copyWith(baseCurrency: currency)); + } + + @override + Future updateThemeMode(int userId, AppThemeMode themeMode) async { + final current = await _requireSettings(userId); + await upsertSettings(current.copyWith(themeMode: themeMode)); + } + + @override + Future updateLocale(int userId, String locale) async { + final current = await _requireSettings(userId); + await upsertSettings(current.copyWith(locale: locale)); + } + + @override + Future 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 _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, +); diff --git a/lib/src/features/settings/domain/entities/settings.dart b/lib/src/features/settings/domain/entities/settings.dart new file mode 100644 index 0000000..98698a8 --- /dev/null +++ b/lib/src/features/settings/domain/entities/settings.dart @@ -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; +} diff --git a/lib/src/features/settings/domain/repositories/settings_repository.dart b/lib/src/features/settings/domain/repositories/settings_repository.dart new file mode 100644 index 0000000..fa0b34d --- /dev/null +++ b/lib/src/features/settings/domain/repositories/settings_repository.dart @@ -0,0 +1,33 @@ +import '../../../../core/database/converters/enum_converters.dart'; +import '../entities/settings.dart'; + +/// Контракт репозитория настроек профиля. +/// Зависит только от чистого Dart — без Drift, без Flutter. +abstract interface class SettingsRepository { + /// Реактивный поток настроек пользователя. + /// Испускает null, если запись ещё не создана. + Stream watchSettings(int userId); + + /// Однократное чтение настроек. null если не существует. + Future getSettings(int userId); + + /// Создать или обновить настройки (upsert по userId). + Future upsertSettings(Settings settings); + + /// Создать запись с дефолтными значениями, если она ещё не существует. + Future ensureDefaults(int userId); + + // ── Точечные обновления ──────────────────────────────────────────────────── + + /// Изменить валюту по умолчанию. + Future updateBaseCurrency(int userId, String currency); + + /// Изменить тему оформления. + Future updateThemeMode(int userId, AppThemeMode themeMode); + + /// Изменить локаль. + Future updateLocale(int userId, String locale); + + /// Изменить первый день месяца/недели (1–28). + Future updateFirstDayOfMonth(int userId, int day); +} diff --git a/lib/src/features/transactions/application/transaction_providers.dart b/lib/src/features/transactions/application/transaction_providers.dart new file mode 100644 index 0000000..fa3a804 --- /dev/null +++ b/lib/src/features/transactions/application/transaction_providers.dart @@ -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); +} diff --git a/lib/src/features/transactions/application/transactions_controller.dart b/lib/src/features/transactions/application/transactions_controller.dart new file mode 100644 index 0000000..3ec35ed --- /dev/null +++ b/lib/src/features/transactions/application/transactions_controller.dart @@ -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> 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 build() => const AsyncData(null); + + /// Создаёт новую транзакцию и возвращает сохранённую сущность. + /// + /// [amount] должен быть > 0 (минорные единицы). + /// Для перевода ([TransactionType.transfer]) передайте [transferToAccountId]. + Future 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 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 deleteTransaction(int id) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(transactionRepositoryProvider).delete(id), + ).then((_) => const AsyncData(null)); + } +} diff --git a/lib/src/features/transactions/data/mappers/transaction_mapper.dart b/lib/src/features/transactions/data/mappers/transaction_mapper.dart new file mode 100644 index 0000000..779b53f --- /dev/null +++ b/lib/src/features/transactions/data/mappers/transaction_mapper.dart @@ -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, + ); +} diff --git a/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart b/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart new file mode 100644 index 0000000..7329745 --- /dev/null +++ b/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart @@ -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> 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 findById(int id) async { + final row = await _dao.findById(id); + return row?.toDomain(); + } + + @override + Future 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 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 delete(int id) async { + await _dao.deleteTransaction(id); + } +} diff --git a/lib/src/features/transactions/domain/entities/transaction.dart b/lib/src/features/transactions/domain/entities/transaction.dart new file mode 100644 index 0000000..13fb784 --- /dev/null +++ b/lib/src/features/transactions/domain/entities/transaction.dart @@ -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; +} diff --git a/lib/src/features/transactions/domain/repositories/transaction_repository.dart b/lib/src/features/transactions/domain/repositories/transaction_repository.dart new file mode 100644 index 0000000..f96a3cc --- /dev/null +++ b/lib/src/features/transactions/domain/repositories/transaction_repository.dart @@ -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> watchTransactions({ + required int userId, + int? accountId, + int? categoryId, + TransactionType? type, + DateTime? from, + DateTime? to, + }); + + Future findById(int id); + + Future 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 update(Transaction transaction); + + Future delete(int id); +} diff --git a/lib/src/features/user/application/active_user_controller.dart b/lib/src/features/user/application/active_user_controller.dart new file mode 100644 index 0000000..f419c85 --- /dev/null +++ b/lib/src/features/user/application/active_user_controller.dart @@ -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 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 setActiveUser(User user) async { + final db = ref.read(appDatabaseProvider); + await db.settingsDao.setPreference(_kActiveUserKey, user.id.toString()); + state = AsyncData(user); + } + + Future clearActiveUser() async { + final db = ref.read(appDatabaseProvider); + await db.settingsDao.deletePreference(_kActiveUserKey); + state = const AsyncData(null); + } +} diff --git a/lib/src/features/user/application/user_providers.dart b/lib/src/features/user/application/user_providers.dart new file mode 100644 index 0000000..3dc2e12 --- /dev/null +++ b/lib/src/features/user/application/user_providers.dart @@ -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); +} diff --git a/lib/src/features/user/application/users_controller.dart b/lib/src/features/user/application/users_controller.dart new file mode 100644 index 0000000..0ecaa29 --- /dev/null +++ b/lib/src/features/user/application/users_controller.dart @@ -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> usersStream(UsersStreamRef ref) => + ref.watch(userRepositoryProvider).watchAll(); + +/// Контроллер операций над профилями. +@riverpod +class UsersController extends _$UsersController { + @override + AsyncValue build() => const AsyncData(null); + + Future 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 renameUser(int id, String newName) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(userRepositoryProvider).rename(id, newName), + ).then((_) => const AsyncData(null)); + } + + Future deleteUser(int id) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(userRepositoryProvider).delete(id), + ).then((_) => const AsyncData(null)); + } +} diff --git a/lib/src/features/user/data/mappers/user_mapper.dart b/lib/src/features/user/data/mappers/user_mapper.dart new file mode 100644 index 0000000..ac9d486 --- /dev/null +++ b/lib/src/features/user/data/mappers/user_mapper.dart @@ -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, + ); +} diff --git a/lib/src/features/user/data/repositories/user_repository_impl.dart b/lib/src/features/user/data/repositories/user_repository_impl.dart new file mode 100644 index 0000000..9d8a228 --- /dev/null +++ b/lib/src/features/user/data/repositories/user_repository_impl.dart @@ -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> watchAll() => + _dao.watchAll().map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Future findById(int id) async { + final row = await _dao.findById(id); + return row?.toDomain(); + } + + @override + Future create(String name) async { + final id = await _dao.insertUser( + UsersTableCompanion.insert(name: name), + ); + final row = await _dao.findById(id); + return row!.toDomain(); + } + + @override + Future 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 delete(int id) => _dao.deleteUser(id); +} diff --git a/lib/src/features/user/domain/entities/user.dart b/lib/src/features/user/domain/entities/user.dart new file mode 100644 index 0000000..b79a392 --- /dev/null +++ b/lib/src/features/user/domain/entities/user.dart @@ -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; +} diff --git a/lib/src/features/user/domain/repositories/user_repository.dart b/lib/src/features/user/domain/repositories/user_repository.dart new file mode 100644 index 0000000..a50335b --- /dev/null +++ b/lib/src/features/user/domain/repositories/user_repository.dart @@ -0,0 +1,20 @@ +import '../entities/user.dart'; + +/// Контракт репозитория пользователей. +/// Зависит только от чистого Dart — без Drift, без Flutter. +abstract interface class UserRepository { + /// Реактивный поток всех профилей. + Stream> watchAll(); + + /// Найти пользователя по id. null если не найден. + Future findById(int id); + + /// Создать новый профиль. Возвращает созданную сущность. + Future create(String name); + + /// Переименовать профиль. + Future rename(int id, String newName); + + /// Удалить профиль (каскадно удаляет все связанные данные). + Future delete(int id); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..fa61e04 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..2dbd65f --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..c1f2029 --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +}