Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e27dbf2ee | ||
|
|
b3fd06f37b | ||
|
|
2f46e7a554 | ||
|
|
d90a3aef16 | ||
|
|
ed77c948d7 | ||
|
|
2ac3f27d64 | ||
|
|
beb7e62d91 | ||
|
|
5a682ea4c6 | ||
|
|
b20e751437 | ||
|
|
7f6e608ff1 | ||
|
|
3931c76623 | ||
|
|
84dec567e0 | ||
|
|
3545dfacd7 | ||
|
|
184cc82469 | ||
|
|
050153b5ec | ||
|
|
ddf3d45bf4 | ||
|
|
cfec739c97 | ||
|
|
0a6267ec3a | ||
|
|
d9c70c1bb6 | ||
|
|
4fee4b7eda | ||
|
|
28a7bd6ea7 | ||
|
|
024037a233 | ||
|
|
a1f020b141 | ||
|
|
65e72dbe9c | ||
|
|
8ce8add138 | ||
|
|
8c9196f67f | ||
|
|
c6d7018b4c | ||
|
|
a0f3c016fa | ||
|
|
f14cde0247 | ||
|
|
c5da00c013 | ||
|
|
e90aa8c8cc | ||
|
|
a4c6023483 | ||
|
|
119f86eecf | ||
|
|
330cc4c14a | ||
|
|
1deea23783 | ||
|
|
5a8bd79387 | ||
|
|
010604ac6f | ||
|
|
4e74cad421 | ||
|
|
66ae257773 | ||
|
|
f411106154 | ||
|
|
49238fbded | ||
|
|
59265f53c2 | ||
|
|
7f8ed7245d | ||
|
|
7e3a759282 | ||
|
|
305f263735 | ||
|
|
ca522cde97 | ||
|
|
d64211acb3 | ||
|
|
8ab4288cda | ||
|
|
7764ec092e | ||
|
|
a4d97db720 | ||
|
|
c5cf706f38 | ||
|
|
b9bf455631 | ||
|
|
b9bd506ee6 | ||
|
|
d84778a5a7 | ||
|
|
f4ac8e0e56 | ||
|
|
42046b4e87 |
Generated
+12
@@ -428,6 +428,18 @@
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="komodo" />
|
||||
<option name="id" value="komodo" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro XL" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
|
||||
Generated
+82
-10
@@ -12,7 +12,7 @@
|
||||
<entry key="analyzer">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/analyzer-7.4.4/lib" />
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/analyzer-7.4.5/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
@@ -173,14 +173,14 @@
|
||||
<entry key="drift">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift-2.26.0/lib" />
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift-2.26.1/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="drift_dev">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift_dev-2.26.0/lib" />
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift_dev-2.26.1/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
@@ -247,6 +247,20 @@
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="flutter_web_auth">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_web_auth-0.6.0/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="flutter_web_plugins">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/../../../../../Sanders/Flutter/flutter/packages/flutter_web_plugins/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="frontend_server_client">
|
||||
<value>
|
||||
<list>
|
||||
@@ -271,7 +285,7 @@
|
||||
<entry key="http">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/http-1.3.0/lib" />
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/http-1.4.0/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
@@ -478,6 +492,55 @@
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shared_preferences">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences-2.5.3/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shared_preferences_android">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_android-2.4.10/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shared_preferences_foundation">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shared_preferences_linux">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shared_preferences_platform_interface">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_platform_interface-2.4.1/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shared_preferences_web">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_web-2.4.3/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shared_preferences_windows">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_windows-2.4.1/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="shelf">
|
||||
<value>
|
||||
<list>
|
||||
@@ -621,7 +684,7 @@
|
||||
<entry key="web_socket">
|
||||
<value>
|
||||
<list>
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/web_socket-1.0.0/lib" />
|
||||
<option value="$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/web_socket-1.0.1/lib" />
|
||||
</list>
|
||||
</value>
|
||||
</entry>
|
||||
@@ -652,8 +715,9 @@
|
||||
<root url="file://$PROJECT_DIR$/../../../../../Sanders/Flutter/flutter/bin/cache/pkg/sky_engine/lib" />
|
||||
<root url="file://$PROJECT_DIR$/../../../../../Sanders/Flutter/flutter/packages/flutter/lib" />
|
||||
<root url="file://$PROJECT_DIR$/../../../../../Sanders/Flutter/flutter/packages/flutter_test/lib" />
|
||||
<root url="file://$PROJECT_DIR$/../../../../../Sanders/Flutter/flutter/packages/flutter_web_plugins/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/_fe_analyzer_shared-82.0.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/analyzer-7.4.4/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/analyzer-7.4.5/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/args-2.7.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/async-2.12.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/boolean_selector-2.1.2/lib" />
|
||||
@@ -676,8 +740,8 @@
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/crypto-3.0.6/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/cupertino_icons-1.0.8/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/dart_style-3.0.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift-2.26.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift_dev-2.26.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift-2.26.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/drift_dev-2.26.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/equatable-2.0.7/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/fake_async-1.3.2/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/ffi-2.1.4/lib" />
|
||||
@@ -685,10 +749,11 @@
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/fixnum-1.1.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/fl_chart-0.71.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_lints-5.0.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_web_auth-0.6.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/frontend_server_client-4.0.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/glob-2.1.3/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/graphs-2.3.2/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/http-1.3.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/http-1.4.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/http_multi_server-3.2.2/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/http_parser-4.1.2/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/intl-0.19.0/lib" />
|
||||
@@ -718,6 +783,13 @@
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/pub_semver-2.2.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/pubspec_parse-1.5.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/recase-4.1.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences-2.5.3/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_android-2.4.10/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_platform_interface-2.4.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_web-2.4.3/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_windows-2.4.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shelf-1.4.2/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/shelf_web_socket-3.0.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/source_gen-2.0.0/lib" />
|
||||
@@ -737,7 +809,7 @@
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/vm_service-14.3.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/watcher-1.1.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/web-1.1.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/web_socket-1.0.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/web_socket-1.0.1/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/web_socket_channel-3.0.3/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/xdg_directories-1.1.0/lib" />
|
||||
<root url="file://$USER_HOME$/AppData/Local/Pub/Cache/hosted/pub.dev/yaml-3.1.3/lib" />
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" project-jdk-name="Android API 35, extension level 13 Platform" project-jdk-type="Android SDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="finance_app"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -19,3 +19,4 @@ subprojects {
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_web_auth/flutter_web_auth.dart'; // Для аутентификации через веб
|
||||
import 'package:shared_preferences/shared_preferences.dart'; // Импортируем для работы с локальным хранилищем
|
||||
import 'package:http/http.dart' as http; // Для HTTP запросов
|
||||
import 'dart:convert'; // Для работы с JSON
|
||||
|
||||
class TelegramAuth {
|
||||
static const String _telegramBotToken = 'YOUR_TELEGRAM_BOT_TOKEN'; // Замените на ваш токен бота
|
||||
static const String _telegramLoginUrl = 'https://telegram.me/YOUR_BOT_NAME'; // Замените на URL вашего бота
|
||||
|
||||
// Метод для аутентификации через Telegram
|
||||
static Future<void> loginWithTelegram(BuildContext context) async {
|
||||
final result = await FlutterWebAuth.authenticate(
|
||||
url: '$_telegramLoginUrl',
|
||||
callbackUrlScheme: 'yourapp', // Замените на ваш callback URL scheme
|
||||
);
|
||||
|
||||
// Получаем код авторизации из URL
|
||||
final code = Uri.parse(result).queryParameters['code'];
|
||||
|
||||
// Отправляем код на сервер для проверки
|
||||
final response = await http.post(
|
||||
Uri.parse('https://api.telegram.org/bot$_telegramBotToken/getMe'),
|
||||
body: {'code': code},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Успешная аутентификация
|
||||
final userData = json.decode(response.body);
|
||||
// Сохраняем данные пользователя в локальном хранилище
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('userId', userData['id']); // Сохраняем ID пользователя
|
||||
await prefs.setString('userName', userData['name']); // Сохраняем имя пользователя
|
||||
await prefs.setString('userEmail', userData['email']); // Сохраняем email пользователя
|
||||
print('User data: $userData');
|
||||
} else {
|
||||
// Ошибка аутентификации
|
||||
print('Error logging in with Telegram: ${response.body}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// ignore_for_file: unused_import // Часто генерируется при условном импорте
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart' as c; // Use alias 'c' for flutter material
|
||||
// Условный импорт бэкенда базы данных
|
||||
// Выбирает реализацию connect() в зависимости от платформы
|
||||
import 'database_connection/connection.dart' // Базовый импорт
|
||||
@@ -9,25 +11,49 @@ import 'database_connection/connection.dart' // Базовый импорт
|
||||
|
||||
// Импортируем модель категории для возвращаемого типа и утилиты
|
||||
import '../models/category.dart';
|
||||
import '../models/user.dart'; // Импортируем модель пользователя
|
||||
import '../utils/category_utils.dart'; // Helper for category details
|
||||
|
||||
// Эта строка указывает Drift сгенерировать файл database.g.dart
|
||||
part 'database.g.dart';
|
||||
|
||||
// Определение таблицы Transactions
|
||||
// Enum for transaction type (used internally and potentially externally)
|
||||
enum TransactionType { income, expense }
|
||||
|
||||
// Определение таблицы Categories
|
||||
// ИСПРАВЛЕНО: Имя класса таблицы изменено на Categories для ясности
|
||||
@DataClassName('CategoryDb')
|
||||
class Categories extends Table { // Changed class name to plural 'Categories'
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get name => text().unique()();
|
||||
TextColumn get icon => text()(); // Store icon name (e.g., 'shopping_cart_outlined')
|
||||
IntColumn get userId => integer().nullable()(); // Ссылка на пользователя
|
||||
IntColumn get color => integer()(); // Store color value (e.g., Colors.green.value)
|
||||
}
|
||||
|
||||
@DataClassName('User') // Имя класса для Drift
|
||||
class Users extends Table { // Определение таблицы Users
|
||||
IntColumn get id => integer().autoIncrement()(); // ID пользователя
|
||||
TextColumn get name => text().withLength(min: 1, max: 50)(); // Имя пользователя
|
||||
TextColumn get email => text().unique()(); // Уникальный email
|
||||
TextColumn get password => text().withLength(min: 6)(); // Пароль (хранить в зашифрованном виде)
|
||||
}
|
||||
// Имя таблицы в SQL будет 'transactions' (snake_case от имени класса)
|
||||
@DataClassName('Transaction') // Keep the generated class name as Transaction
|
||||
class Transactions extends Table {
|
||||
IntColumn get id => integer().autoIncrement()(); // Primary key
|
||||
TextColumn get categoryName => text().named('category_name')(); // Имя категории
|
||||
TextColumn get categoryName => text().named('category_name')(); // Имя категории (или 'Income')
|
||||
RealColumn get amount => real()(); // Сумма транзакции
|
||||
DateTimeColumn get date => dateTime()(); // Дата транзакции
|
||||
TextColumn get merchant => text()(); // Название продавца/магазина
|
||||
TextColumn get merchant => text().named('merchant')(); // Название продавца/источника
|
||||
// ИСПРАВЛЕНО: Добавлено .withDefault() для значения по умолчанию
|
||||
IntColumn get userId => integer().nullable()(); // Ссылка на пользователя
|
||||
TextColumn get type => text().named('type').withDefault(const Constant('expense'))(); // Тип транзакции ('income' или 'expense')
|
||||
}
|
||||
|
||||
// Класс базы данных
|
||||
// Аннотация @DriftDatabase указывает Drift сгенерировать код для этой базы данных
|
||||
@DriftDatabase(tables: [Transactions])
|
||||
@DriftDatabase(tables: [Categories, Transactions, Users]) // Добавляем Users в список таблиц
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
// Используем функцию connect() из условного импорта для создания соединения
|
||||
// Во время компиляции будет выбрана правильная реализация connect()
|
||||
@@ -36,7 +62,58 @@ class AppDatabase extends _$AppDatabase {
|
||||
|
||||
// Версия схемы. Увеличивайте при изменении структуры таблиц.
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 4; // Increased version due to adding Categories table
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) async {
|
||||
await m.createAll();
|
||||
// Вставляем начальные данные ТОЛЬКО при создании базы данных
|
||||
print("Database created. Inserting initial data...");
|
||||
// ИЗМЕНЕНО: Вызов insertInitialDataIfNeeded теперь только здесь
|
||||
await insertInitialDataIfNeeded();
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
// Drift автоматически обработает добавление новых таблиц (Categories)
|
||||
// при обновлении до версии 4.
|
||||
// Нам нужно только обработать специфичные изменения, как добавление колонки type.
|
||||
if (from < 4) { // Check if upgrading from a version before 4
|
||||
// Check if the 'type' column exists before trying to add it
|
||||
// This requires a more complex check, usually involving inspecting the schema.
|
||||
// For simplicity, we assume if version is < 4, the column might be missing.
|
||||
// A safer approach involves querying PRAGMA table_info(transactions);
|
||||
// However, Drift's default migration handles adding columns well.
|
||||
// Let's ensure the Categories table is created if upgrading from very old versions.
|
||||
await m.createTable(categories); // Ensure categories table exists
|
||||
// Add the 'type' column if it doesn't exist (Drift might handle this, but explicit is safer)
|
||||
// We'll rely on Drift's default behavior for adding the column here.
|
||||
// If specific default values or constraints were needed during upgrade,
|
||||
// more complex logic would be required.
|
||||
}
|
||||
if (from == 1) {
|
||||
// Example: If migrating specifically from 1, maybe add the type column
|
||||
// await m.addColumn(transactions, transactions.type);
|
||||
// But the check `from < 4` above is more general if relying on Drift's auto-migration.
|
||||
}
|
||||
// Add more migration steps for future versions here
|
||||
// if (from < 5) { ... }
|
||||
|
||||
// ИЗМЕНЕНО: Удален вызов insertInitialDataIfNeeded отсюда
|
||||
// Не нужно вставлять начальные данные при обновлении существующей БД.
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
// ИЗМЕНЕНО: Удален вызов insertInitialDataIfNeeded отсюда
|
||||
// Логика вставки начальных данных теперь полностью в onCreate.
|
||||
if (details.wasCreated) {
|
||||
print("Database was created. Initial data should have been inserted via onCreate.");
|
||||
} else {
|
||||
print("Opening existing database version ${details.versionNow}.");
|
||||
}
|
||||
// Можно добавить здесь другие проверки или настройки при открытии, если нужно.
|
||||
return Future.value(); // beforeOpen должен возвращать Future<void>
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// --- Методы для работы с транзакциями ---
|
||||
|
||||
@@ -48,71 +125,301 @@ class AppDatabase extends _$AppDatabase {
|
||||
.watch();
|
||||
}
|
||||
|
||||
// Получить транзакции, отфильтрованные по категории, в виде потока
|
||||
// Получить транзакции, отфильтрованные по категории РАСХОДОВ, в виде потока
|
||||
// Если categoryName == 'All', возвращает ВСЕ транзакции (и доходы, и расходы)
|
||||
// Возвращает non-nullable Stream<List<Transaction>> (Transaction - сгенерированный Drift класс)
|
||||
Stream<List<Transaction>> watchFilteredTransactions(String categoryName) {
|
||||
if (categoryName == 'All') {
|
||||
// watchAllTransactions теперь возвращает non-nullable Stream
|
||||
return watchAllTransactions(); // Return all if filter is 'All'
|
||||
return watchAllTransactions(); // Return all types if filter is 'All'
|
||||
}
|
||||
// Otherwise, filter by the provided category name
|
||||
// Otherwise, filter by the provided category name AND ensure it's an expense
|
||||
return (select(transactions)
|
||||
..where((t) => t.categoryName.equals(categoryName))
|
||||
..where((t) => t.categoryName.equals(categoryName) & t.type.equals('expense'))
|
||||
..orderBy([(t) => OrderingTerm(expression: t.date, mode: OrderingMode.desc)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
// Добавить новую транзакцию
|
||||
// Принимает TransactionsCompanion - сгенерированный Drift класс
|
||||
// Добавить новую транзакцию, включая userId
|
||||
// Принимает TransactionsCompanion - сгенерированный Drift класс (должен включать type)
|
||||
Future<int> addTransaction(TransactionsCompanion entry) {
|
||||
// Убедимся, что тип указан
|
||||
assert(entry.type.present && (entry.type.value == 'income' || entry.type.value == 'expense'));
|
||||
// Убедимся, что для дохода используется специальная категория
|
||||
if (entry.type.value == 'income') {
|
||||
assert(entry.categoryName.present && entry.categoryName.value == 'Income');
|
||||
}
|
||||
return into(transactions).insert(entry);
|
||||
}
|
||||
|
||||
// --- Методы для работы с категориями (вычисляются из транзакций) ---
|
||||
// Обновить существующую транзакцию
|
||||
// Принимает TransactionsCompanion, который должен содержать ID
|
||||
// Возвращает true, если обновление прошло успешно
|
||||
Future<bool> updateTransaction(TransactionsCompanion entry) {
|
||||
// Проверяем, что ID предоставлен для обновления
|
||||
assert(entry.id.present);
|
||||
// Убедимся, что тип указан
|
||||
assert(entry.type.present && (entry.type.value == 'income' || entry.type.value == 'expense'));
|
||||
// Убедимся, что для дохода используется специальная категория
|
||||
if (entry.type.value == 'income') {
|
||||
assert(entry.categoryName.present && entry.categoryName.value == 'Income');
|
||||
}
|
||||
|
||||
// Вычислить и наблюдать за общими суммами по категориям
|
||||
// Используем .replace() для обновления записи по ID
|
||||
// replace возвращает true, если запись была обновлена (найдена по ID)
|
||||
return update(transactions).replace(entry);
|
||||
}
|
||||
|
||||
// Удалить транзакцию по ID
|
||||
// Возвращает количество удаленных строк (0 или 1)
|
||||
Future<int> deleteTransaction(int id) {
|
||||
// Используем .delete() с условием where
|
||||
return (delete(transactions)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
|
||||
// --- Методы для работы с категориями ---
|
||||
|
||||
// Получить все категории в виде потока, упорядоченные по имени
|
||||
// Возвращает Stream<List<CategoryDb>> (CategoryDb - сгенерированный Drift класс)
|
||||
Stream<List<CategoryDb>> watchAllCategoriesDb() {
|
||||
return (select(categories)..orderBy([(c) => OrderingTerm(expression: c.name)])).watch();
|
||||
}
|
||||
|
||||
// Добавить новую категорию
|
||||
// Принимает CategoriesCompanion (сгенерированный Drift)
|
||||
// Возвращает ID вставленной категории
|
||||
Future<int> addCategory(CategoriesCompanion entry) {
|
||||
// Проверяем, что имя, иконка и цвет предоставлены
|
||||
assert(entry.name.present && entry.name.value.isNotEmpty);
|
||||
assert(entry.icon.present); // Icon can be empty string if needed
|
||||
assert(entry.color.present);
|
||||
// Не позволяем добавить категорию с именем 'Income' (case-insensitive)
|
||||
assert(entry.name.value.toLowerCase() != 'income');
|
||||
return into(categories).insert(entry);
|
||||
}
|
||||
|
||||
// Обновить существующую категорию
|
||||
// Принимает CategoriesCompanion, который должен содержать ID
|
||||
// Возвращает true, если обновление прошло успешно
|
||||
Future<bool> updateCategory(CategoriesCompanion entry) {
|
||||
// Проверяем, что ID предоставлен для обновления
|
||||
assert(entry.id.present);
|
||||
// Проверяем, что имя не 'Income' (case-insensitive)
|
||||
if (entry.name.present && entry.name.value.toLowerCase() == 'income') {
|
||||
print("Error: Cannot rename category to 'Income'.");
|
||||
return Future.value(false); // Запрещаем переименование в 'Income'
|
||||
}
|
||||
|
||||
// Используем транзакцию для проверки и обновления
|
||||
return transaction(() async {
|
||||
// Находим категорию по ID перед обновлением
|
||||
final existingCategory = await (select(categories)..where((c) => c.id.equals(entry.id.value))).getSingleOrNull();
|
||||
|
||||
// Проверяем, существует ли категория и не является ли она 'Income'
|
||||
if (existingCategory == null) {
|
||||
print("Error: Category with ID ${entry.id.value} not found for update.");
|
||||
return false; // Категория не найдена
|
||||
}
|
||||
if (existingCategory.name == 'Income') {
|
||||
print("Error: Cannot update the 'Income' category.");
|
||||
return false; // Запрещаем обновление категории 'Income'
|
||||
}
|
||||
|
||||
// Выполняем обновление
|
||||
// Метод replace возвращает true, если запись была обновлена
|
||||
final updated = await update(categories).replace(entry);
|
||||
|
||||
// Если имя категории было изменено, нужно обновить categoryName во всех связанных транзакциях
|
||||
if (updated && entry.name.present && entry.name.value != existingCategory.name) {
|
||||
print("Category name changed from '${existingCategory.name}' to '${entry.name.value}'. Updating transactions...");
|
||||
final updatedTransactions = await (update(transactions)
|
||||
..where((t) => t.categoryName.equals(existingCategory.name)))
|
||||
.write(TransactionsCompanion(
|
||||
categoryName: Value(entry.name.value),
|
||||
));
|
||||
print("Updated $updatedTransactions transactions with the new category name.");
|
||||
}
|
||||
|
||||
return updated; // Возвращаем результат replace
|
||||
});
|
||||
}
|
||||
|
||||
// Удалить категорию по ID
|
||||
// Возвращает количество удаленных строк (0 или 1)
|
||||
Future<int> deleteCategory(int id) {
|
||||
// Используем транзакцию для проверок и удаления
|
||||
return transaction(() async {
|
||||
// Находим категорию по ID перед удалением
|
||||
final categoryToDelete = await (select(categories)..where((c) => c.id.equals(id))).getSingleOrNull();
|
||||
|
||||
// Проверяем, существует ли категория
|
||||
if (categoryToDelete == null) {
|
||||
print("Error: Category with ID $id not found for deletion.");
|
||||
return 0; // Категория не найдена
|
||||
}
|
||||
|
||||
// Запрещаем удаление категории 'Income'
|
||||
if (categoryToDelete.name == 'Income') {
|
||||
print("Error: Cannot delete the 'Income' category.");
|
||||
return 0; // Возвращаем 0, т.к. ничего не удалено
|
||||
}
|
||||
|
||||
// Проверяем, есть ли транзакции с этой категорией
|
||||
// Используем имя категории для связи (т.к. нет внешнего ключа)
|
||||
final query = selectOnly(transactions)
|
||||
..addColumns([transactions.id.count()])
|
||||
..where(transactions.categoryName.equals(categoryToDelete.name)); // Используем имя для связи
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
final transactionCount = result?.read(transactions.id.count()) ?? 0;
|
||||
|
||||
if (transactionCount > 0) {
|
||||
print("Error: Cannot delete category '${categoryToDelete.name}' because it has $transactionCount associated transaction(s).");
|
||||
// Можно выбросить исключение или вернуть 0, чтобы показать неудачу
|
||||
// throw Exception("Cannot delete category with transactions.");
|
||||
return 0; // Не удаляем, возвращаем 0
|
||||
}
|
||||
|
||||
// Если транзакций нет, удаляем категорию
|
||||
print("Deleting category '${categoryToDelete.name}' (ID: $id)...");
|
||||
final deletedRows = await (delete(categories)..where((c) => c.id.equals(id))).go();
|
||||
print("Deleted $deletedRows category row(s).");
|
||||
return deletedRows; // Возвращаем количество удаленных строк
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Получить категорию по ID (если нужно)
|
||||
Future<CategoryDb?> getCategoryById(int id) {
|
||||
return (select(categories)..where((c) => c.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
// --- Методы для агрегации и отчетов ---
|
||||
|
||||
// Вычислить и наблюдать за общими суммами по категориям РАСХОДОВ
|
||||
// Возвращает Stream<List<Category>> где Category - это класс модели из '../models/category.dart'
|
||||
Stream<List<Category>> calculateCategoryTotals() {
|
||||
// 1. Получаем поток всех транзакций (теперь non-nullable Stream<List<Transaction>>)
|
||||
return watchAllTransactions().map((transactionList) {
|
||||
// 2. Группируем транзакции по categoryName и суммируем amount
|
||||
// 1. Создаем поток, который объединяет транзакции и категории
|
||||
final transactionsStream = watchAllTransactions();
|
||||
final categoriesStream = watchAllCategoriesDb();
|
||||
|
||||
// Используем StreamZip для объединения последних данных из обоих потоков
|
||||
return StreamZip([transactionsStream, categoriesStream]).map((data) {
|
||||
final transactionList = data[0] as List<Transaction>;
|
||||
final categoryList = data[1] as List<CategoryDb>;
|
||||
|
||||
// Создаем Map для быстрого доступа к деталям категории по имени
|
||||
final categoryDetailsMap = {
|
||||
for (var cat in categoryList) cat.name: cat
|
||||
};
|
||||
|
||||
// 2. Фильтруем только расходы и группируем по categoryName, суммируя amount
|
||||
final categoryTotals = <String, double>{};
|
||||
// transactionList теперь содержит объекты Transaction, сгенерированные Drift
|
||||
for (var transaction in transactionList) {
|
||||
// Суммируем только расходы (исключая 'Income')
|
||||
if (transaction.type == 'expense') {
|
||||
categoryTotals.update(
|
||||
transaction.categoryName,
|
||||
(value) => value + transaction.amount,
|
||||
ifAbsent: () => transaction.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Преобразуем сгруппированные данные в список объектов Category
|
||||
// 3. Преобразуем сгруппированные данные в список объектов Category (модель UI)
|
||||
return categoryTotals.entries.map((entry) {
|
||||
// Используем CategoryUtils для получения деталей (иконка, цвет)
|
||||
// Убедитесь, что CategoryUtils корректно обрабатывает все categoryName,
|
||||
// включая 'Utilities' из начальных данных, или предоставляет дефолтные значения.
|
||||
final categoryDetails = CategoryUtils.getCategoryDetails(entry.key);
|
||||
return Category(
|
||||
entry.key, // name
|
||||
entry.value, // amount
|
||||
categoryDetails.colorCode,
|
||||
categoryDetails.iconCode,
|
||||
final categoryName = entry.key;
|
||||
final totalAmount = entry.value;
|
||||
// Получаем детали категории из Map (или используем дефолтные, если категория была удалена)
|
||||
final categoryDb = categoryDetailsMap[categoryName];
|
||||
final iconData = CategoryUtils.getIconFromString(categoryDb?.icon); // Используем утилиту
|
||||
final colorData = categoryDb != null ? c.Color(categoryDb.color) : c.Colors.grey.shade500; // Цвет из БД или дефолтный
|
||||
|
||||
return Category( // Это Category из models/category.dart
|
||||
categoryName,
|
||||
totalAmount,
|
||||
colorData,
|
||||
iconData,
|
||||
);
|
||||
}).toList()
|
||||
// Сортируем категории по сумме (от большей к меньшей) для диаграммы/легенды
|
||||
// Сортируем категории по сумме (от большей к меньшей)
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
});
|
||||
}
|
||||
|
||||
// Пример добавления начальных данных (если база данных пуста)
|
||||
|
||||
// Вычислить и наблюдать за общей суммой ДОХОДОВ
|
||||
Stream<double> watchTotalIncome() {
|
||||
// Создаем выражение для суммы amount
|
||||
final amountSum = transactions.amount.sum();
|
||||
// Строим запрос: выбрать сумму amount из transactions, где type = 'income'
|
||||
final query = selectOnly(transactions)
|
||||
..addColumns([amountSum])
|
||||
..where(transactions.type.equals('income'));
|
||||
|
||||
// Выполняем запрос и наблюдаем за изменениями
|
||||
// map преобразует результат (единственную строку с суммой) в double
|
||||
// ?? 0.0 обрабатывает случай, когда доходов нет (сумма будет null)
|
||||
return query.watchSingleOrNull().map((result) => result?.read(amountSum) ?? 0.0);
|
||||
}
|
||||
|
||||
// Вычислить и наблюдать за общей суммой РАСХОДОВ (альтернатива суммированию категорий)
|
||||
Stream<double> watchTotalExpenses() {
|
||||
final amountSum = transactions.amount.sum();
|
||||
final query = selectOnly(transactions)
|
||||
..addColumns([amountSum])
|
||||
..where(transactions.type.equals('expense'));
|
||||
return query.watchSingleOrNull().map((result) => result?.read(amountSum) ?? 0.0);
|
||||
}
|
||||
|
||||
|
||||
// Добавление начальных данных (вызывается только из onCreate)
|
||||
// ИЗМЕНЕНО: Убран необязательный параметр isCreating, т.к. вызывается только при создании
|
||||
Future<void> insertInitialDataIfNeeded() async {
|
||||
// Проверяем, есть ли уже транзакции
|
||||
final existingTransactions = await select(transactions).get();
|
||||
if (existingTransactions.isEmpty) {
|
||||
print("Database is empty. Inserting initial data...");
|
||||
// Проверяем, есть ли уже категории (на всякий случай, хотя в onCreate их быть не должно)
|
||||
final categoriesCountResult = await (selectOnly(categories)..addColumns([categories.id.count()])).getSingleOrNull();
|
||||
final categoriesCount = categoriesCountResult?.read(categories.id.count()) ?? 0;
|
||||
|
||||
if (categoriesCount == 0) {
|
||||
print("Inserting initial categories...");
|
||||
// Получаем доступные цвета из CategoryUtils
|
||||
final availableColors = CategoryUtils.availableColors;
|
||||
|
||||
await batch((batch) {
|
||||
batch.insertAll(categories, [
|
||||
// Используем CategoriesCompanion (сгенерированный для таблицы Categories)
|
||||
// Используем реальные имена иконок Material Icons и конкретные значения цветов из списка
|
||||
CategoriesCompanion.insert(name: 'Groceries', icon: 'shopping_cart_outlined', color: availableColors[0].value), // Red
|
||||
CategoriesCompanion.insert(name: 'Subscriptions', icon: 'subscriptions_outlined', color: availableColors[4].value), // Indigo
|
||||
CategoriesCompanion.insert(name: 'Restaurant', icon: 'restaurant_menu_outlined', color: availableColors[14].value), // Orange
|
||||
CategoriesCompanion.insert(name: 'Shopping', icon: 'shopping_bag_outlined', color: availableColors[5].value), // Blue
|
||||
CategoriesCompanion.insert(name: 'Transport', icon: 'directions_bus_filled_outlined', color: availableColors[2].value), // Purple
|
||||
CategoriesCompanion.insert(name: 'Travel', icon: 'flight_takeoff_outlined', color: availableColors[7].value), // Cyan
|
||||
CategoriesCompanion.insert(name: 'Utilities', icon: 'home_outlined', color: availableColors[8].value), // Teal
|
||||
// 'Income' category - use a specific icon and color (можно тоже взять из списка или оставить уникальный)
|
||||
// Оставим уникальный цвет для Income для выделения
|
||||
CategoriesCompanion.insert(name: 'Income', icon: 'attach_money', color: c.Colors.lightGreenAccent.shade700.value),
|
||||
]);
|
||||
});
|
||||
print("Initial categories inserted.");
|
||||
} else {
|
||||
// Эта ветка не должна выполняться при вызове из onCreate, но оставим для отладки
|
||||
print("Categories table already contains data ($categoriesCount categories). Skipping initial category insertion.");
|
||||
}
|
||||
|
||||
// Проверяем, есть ли уже транзакции (аналогично, не должно быть в onCreate)
|
||||
final countResult = await (selectOnly(transactions)..addColumns([transactions.id.count()])).getSingleOrNull();
|
||||
final transactionCount = countResult?.read(transactions.id.count()) ?? 0;
|
||||
|
||||
// Вставляем данные только если таблица транзакций пуста
|
||||
if (transactionCount == 0) {
|
||||
print("Inserting initial transactions...");
|
||||
// Используем batch для эффективной вставки нескольких записей
|
||||
await batch((batch) {
|
||||
batch.insertAll(transactions, [
|
||||
// Используем TransactionsCompanion для создания записей для вставки
|
||||
// Поле type будет 'expense' по умолчанию из-за .withDefault() в определении колонки
|
||||
TransactionsCompanion.insert(categoryName: 'Groceries', amount: 45.99, date: DateTime.now().subtract(const Duration(days: 1, hours: 2)), merchant: 'Whole Foods Market'),
|
||||
TransactionsCompanion.insert(categoryName: 'Subscriptions', amount: 39.99, date: DateTime.now().subtract(const Duration(days: 2, hours: 5)), merchant: 'Netflix Premium'),
|
||||
TransactionsCompanion.insert(categoryName: 'Restaurant', amount: 78.50, date: DateTime.now().subtract(const Duration(days: 2, hours: 19)), merchant: 'Italian Corner'),
|
||||
@@ -120,13 +427,15 @@ class AppDatabase extends _$AppDatabase {
|
||||
TransactionsCompanion.insert(categoryName: 'Groceries', amount: 23.45, date: DateTime.now().subtract(const Duration(days: 4, hours: 9)), merchant: 'Local Market'),
|
||||
TransactionsCompanion.insert(categoryName: 'Transport', amount: 15.00, date: DateTime.now().subtract(const Duration(days: 5, hours: 8)), merchant: 'City Bus'),
|
||||
TransactionsCompanion.insert(categoryName: 'Restaurant', amount: 56.80, date: DateTime.now().subtract(const Duration(days: 5, hours: 20)), merchant: 'Sushi Express'),
|
||||
// Убедитесь, что 'Utilities' определена в CategoryUtils или обрабатывается как неизвестная категория
|
||||
TransactionsCompanion.insert(categoryName: 'Utilities', amount: 85.20, date: DateTime.now().subtract(const Duration(days: 6, hours: 10)), merchant: 'Electricity Bill'),
|
||||
TransactionsCompanion.insert(categoryName: 'Utilities', amount: 85.20, date: DateTime.now().subtract(const Duration(days: 6, hours: 10)), merchant: 'Electricity Bill'), // Example: Added Utilities transaction
|
||||
// Пример дохода - здесь нужно явно указать type: 'income'
|
||||
TransactionsCompanion.insert(categoryName: 'Income', amount: 1200.00, date: DateTime.now().subtract(const Duration(days: 7, hours: 9)), merchant: 'Salary', type: Value('income')),
|
||||
]);
|
||||
});
|
||||
print("Initial data inserted successfully.");
|
||||
print("Initial transactions inserted successfully.");
|
||||
} else {
|
||||
print("Database already contains data (${existingTransactions.length} transactions). Skipping initial data insertion.");
|
||||
// Эта ветка не должна выполняться при вызове из onCreate
|
||||
print("Transactions table already contains data ($transactionCount transactions). Skipping initial transaction insertion.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Category {
|
||||
final int? userId; // Добавляем ссылку на пользователя
|
||||
final String name;
|
||||
final double amount;
|
||||
final Color colorCode;
|
||||
final IconData iconCode;
|
||||
|
||||
Category(this.name, this.amount, this.colorCode, this.iconCode);
|
||||
Category(this.name, this.amount, this.colorCode, this.iconCode, {this.userId});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'category.dart';
|
||||
|
||||
class TransactionRecord {
|
||||
final String categoryName;
|
||||
final int id;
|
||||
final String type; // 'income' or 'expense'
|
||||
final int? userId; // Добавляем ссылку на пользователя
|
||||
final double amount;
|
||||
final IconData iconCode;
|
||||
final Color color;
|
||||
final Category? category; // Reference to category (null for income)
|
||||
final DateTime date;
|
||||
final String merchant;
|
||||
|
||||
TransactionRecord(this.categoryName, this.amount, this.iconCode, this.color, this.date, this.merchant);
|
||||
TransactionRecord({
|
||||
this.userId, // Добавляем параметр для ссылки на пользователя
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
this.category,
|
||||
required this.date,
|
||||
required this.merchant,
|
||||
});
|
||||
|
||||
get id => 1;
|
||||
// Removed the hardcoded id getter
|
||||
// get id => 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
class User {
|
||||
final int id;
|
||||
final String name;
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.email,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
// Метод для преобразования из JSON
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
email: json['email'],
|
||||
password: json['password'],
|
||||
);
|
||||
}
|
||||
|
||||
// Метод для преобразования в JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'password': password,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../auth/telegram_auth.dart'; // Импортируем TelegramAuth для логина
|
||||
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
const ProfileScreen({Key? key}) : super(key: key);
|
||||
@@ -52,7 +53,22 @@ class ProfileScreen extends StatelessWidget {
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
TelegramAuth.loginWithTelegram(context); // Вызов метода логина через Telegram
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark ? Colors.green.shade700 : Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: const Text('Login with Telegram'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../database/database.dart' as db;
|
||||
import 'settings_screen.dart'; // Импортируем переименованный экран
|
||||
|
||||
class SettingsMenuScreen extends StatelessWidget {
|
||||
final db.AppDatabase database;
|
||||
|
||||
const SettingsMenuScreen({Key? key, required this.database}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Настройки'),
|
||||
centerTitle: true,
|
||||
backgroundColor: theme.appBarTheme.backgroundColor,
|
||||
elevation: theme.appBarTheme.elevation,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
children: [
|
||||
// Опция "Редактирование категорий"
|
||||
ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.15),
|
||||
child: Icon(Icons.category_outlined, color: theme.colorScheme.primary, size: 24),
|
||||
),
|
||||
title: Text('Редактирование категорий', style: theme.textTheme.titleMedium),
|
||||
trailing: Icon(Icons.arrow_forward_ios, size: 18, color: theme.colorScheme.onSurface.withOpacity(0.6)),
|
||||
onTap: () {
|
||||
// Переход на экран редактирования категорий
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategorySettingsScreen(database: database),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Добавьте другие опции настроек здесь в будущем
|
||||
// ListTile(...),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import '../database/database.dart' as db;
|
||||
import '../utils/category_utils.dart';
|
||||
import '../widgets/edit_category_dialog.dart'; // Импортируем диалог
|
||||
|
||||
// Переименован класс SettingsScreen в CategorySettingsScreen
|
||||
class CategorySettingsScreen extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
|
||||
const CategorySettingsScreen({Key? key, required this.database}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CategorySettingsScreen> createState() => _CategorySettingsScreenState();
|
||||
}
|
||||
|
||||
// Переименован класс _SettingsScreenState в _CategorySettingsScreenState
|
||||
class _CategorySettingsScreenState extends State<CategorySettingsScreen> {
|
||||
late Stream<List<db.CategoryDb>> _categoriesStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_categoriesStream = widget.database.watchAllCategoriesDb();
|
||||
}
|
||||
|
||||
// Функция для показа диалога добавления/редактирования
|
||||
void _showEditCategoryDialog({db.CategoryDb? categoryToEdit}) async {
|
||||
final result = await showDialog<bool>( // Ожидаем bool (true если сохранено)
|
||||
context: context,
|
||||
builder: (context) => EditCategoryDialog(
|
||||
database: widget.database,
|
||||
categoryToEdit: categoryToEdit, // Передаем категорию для редактирования или null для добавления
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(categoryToEdit == null ? 'Категория добавлена' : 'Категория обновлена'),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для удаления категории (с подтверждением)
|
||||
void _deleteCategory(db.CategoryDb category) async {
|
||||
// Не позволяем удалять 'Income'
|
||||
if (category.name == 'Income') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Категорию "Income" нельзя удалить.'),
|
||||
backgroundColor: Colors.orange,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить категорию?'),
|
||||
content: Text('Вы уверены, что хотите удалить категорию "${category.name}"? Это действие нельзя отменить.\n\nТранзакции с этой категорией могут отображаться некорректно или вызвать ошибки при попытке их отображения, если они не будут переназначены.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false), // Отмена
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true), // Подтвердить
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
try {
|
||||
// Попытка удаления категории из базы данных
|
||||
final deletedRows = await widget.database.deleteCategory(category.id);
|
||||
|
||||
if (deletedRows > 0 && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Категория "${category.name}" удалена.'),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} else if (deletedRows == 0 && mounted) {
|
||||
// Это может произойти, если deleteCategory вернул 0 (например, из-за наличия транзакций)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Не удалось удалить категорию "${category.name}". Возможно, она используется в транзакциях.'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
print("Error deleting category: $e"); // Логируем ошибку
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка при удалении категории: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Редактирование категорий'), // Обновленный заголовок
|
||||
centerTitle: true,
|
||||
backgroundColor: theme.appBarTheme.backgroundColor, // Ensure AppBar color matches theme
|
||||
elevation: theme.appBarTheme.elevation, // Ensure elevation matches theme
|
||||
),
|
||||
body: StreamBuilder<List<db.CategoryDb>>(
|
||||
stream: _categoriesStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Ошибка загрузки категорий: ${snapshot.error}'));
|
||||
}
|
||||
final categories = snapshot.data ?? [];
|
||||
// Фильтруем категорию 'Income', чтобы ее нельзя было редактировать/удалять отсюда
|
||||
final editableCategories = categories.where((c) => c.name != 'Income').toList();
|
||||
|
||||
if (editableCategories.isEmpty && snapshot.connectionState != ConnectionState.waiting) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
'Нет категорий для редактирования.\nНажмите "+", чтобы добавить новую категорию расходов.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0), // Add padding around the list
|
||||
itemCount: editableCategories.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 72, // Indent to align with text after avatar
|
||||
endIndent: 16,
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final category = editableCategories[index];
|
||||
final iconData = CategoryUtils.getIconFromString(category.icon);
|
||||
final colorData = Color(category.color);
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 22, // Slightly larger avatar
|
||||
backgroundColor: colorData.withOpacity(isDark ? 0.3 : 0.15),
|
||||
child: Icon(iconData, color: colorData, size: 24),
|
||||
),
|
||||
title: Text(category.name, style: theme.textTheme.titleMedium),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit_outlined, color: theme.colorScheme.primary.withOpacity(0.8)),
|
||||
tooltip: 'Редактировать',
|
||||
splashRadius: 24,
|
||||
onPressed: () => _showEditCategoryDialog(categoryToEdit: category),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: Colors.red.shade400.withOpacity(0.8)),
|
||||
tooltip: 'Удалить',
|
||||
splashRadius: 24,
|
||||
onPressed: () => _deleteCategory(category),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _showEditCategoryDialog(categoryToEdit: category), // Тоже открывает редактирование
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showEditCategoryDialog(), // Вызов без аргумента для добавления
|
||||
tooltip: 'Добавить категорию',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,47 +5,243 @@ import '../models/category.dart'; // Import the Category model for return types
|
||||
class CategoryUtils {
|
||||
// Private map storing details for each predefined category name.
|
||||
// Using a record `({IconData icon, Color color})` for concise structure.
|
||||
static final Map<String, ({IconData iconCode, Color colorCode})> _categoryDetails = {
|
||||
'Groceries': (iconCode: Icons.shopping_cart_outlined, colorCode: Colors.green.shade400),
|
||||
'Subscriptions': (iconCode: Icons.subscriptions_outlined, colorCode: Colors.orange.shade400),
|
||||
'Restaurant': (iconCode: Icons.restaurant_menu_outlined, colorCode: Colors.red.shade400),
|
||||
'Shopping': (iconCode: Icons.shopping_bag_outlined, colorCode: Colors.blue.shade400),
|
||||
'Transport': (iconCode: Icons.directions_bus_filled_outlined, colorCode: Colors.purple.shade400),
|
||||
'Travel': (iconCode: Icons.flight_takeoff_outlined, colorCode: Colors.cyan.shade400),
|
||||
'Utilities': (iconCode: Icons.lightbulb_outline, colorCode: Colors.yellow.shade700),
|
||||
'Health': (iconCode: Icons.local_hospital_outlined, colorCode: Colors.pink.shade300),
|
||||
'Entertainment': (iconCode: Icons.movie_filter_outlined, colorCode: Colors.teal.shade400),
|
||||
'Other': (iconCode: Icons.category_outlined, colorCode: Colors.grey.shade500), // Default/fallback
|
||||
};
|
||||
// This map is mainly for initial data and potentially fallback display logic.
|
||||
// The actual icon/color for user-created categories will come from the database.
|
||||
// DEPRECATED: Rely on _stringToIconData and database color instead.
|
||||
// static final Map<String, ({IconData iconCode, Color colorCode})> _categoryDetails = { ... };
|
||||
|
||||
// Default details to return if a category name is not found in the map.
|
||||
static final _defaultDetails = (iconCode: Icons.category_outlined, colorCode: Colors.grey.shade500);
|
||||
// Default details to return if a category name is not found (e.g., deleted category).
|
||||
// DEPRECATED: Use getIconFromString and default color instead.
|
||||
// static final _defaultDetails = (iconCode: Icons.category_outlined, colorCode: Colors.grey.shade500);
|
||||
|
||||
/// Returns the IconData and Color associated with a given category name.
|
||||
///
|
||||
/// If the `categoryName` is found in the predefined map, its details are returned.
|
||||
/// Otherwise, default icon and color are returned.
|
||||
/// DEPRECATED: Returns the IconData and Color associated with a given category name (primarily for fallback).
|
||||
/// It's better to rely on data fetched directly from the database (CategoryDb).
|
||||
static ({Color colorCode, IconData iconCode}) getCategoryDetails(String categoryName) {
|
||||
// Use the null-aware operator `??` to provide default values if the key doesn't exist.
|
||||
return _categoryDetails[categoryName] ?? _defaultDetails;
|
||||
// Fallback logic using the string-to-icon map and a default color
|
||||
final icon = getIconFromString(categoryName); // Use the reliable method
|
||||
// Find color from initial data if possible, otherwise default grey
|
||||
// This part is still weak, ideally color comes from DB.
|
||||
Color color = Colors.grey.shade500; // Default color
|
||||
// Quick check against some known initial names for color fallback
|
||||
if (categoryName == 'Groceries') color = Colors.green.shade400;
|
||||
if (categoryName == 'Subscriptions') color = Colors.orange.shade400;
|
||||
if (categoryName == 'Restaurant') color = Colors.red.shade400;
|
||||
if (categoryName == 'Shopping') color = Colors.blue.shade400;
|
||||
if (categoryName == 'Transport') color = Colors.purple.shade400;
|
||||
if (categoryName == 'Travel') color = Colors.cyan.shade400;
|
||||
if (categoryName == 'Utilities') color = Colors.teal.shade400; // Updated from home_outlined
|
||||
if (categoryName == 'Income') color = Colors.lightGreenAccent.shade700;
|
||||
|
||||
return (colorCode: color, iconCode: icon);
|
||||
}
|
||||
|
||||
/// Returns a `Category` object based on its name and amount.
|
||||
///
|
||||
/// This is useful when you have the name and amount (e.g., from database aggregation)
|
||||
/// and need to construct a full `Category` object including the icon and color.
|
||||
/// DEPRECATED: Returns a `Category` object based on its name and amount (primarily for UI models like Pie Chart).
|
||||
/// Fetches icon/color details using `getCategoryDetails` as a fallback mechanism.
|
||||
/// Should be replaced by logic that uses CategoryDb data directly.
|
||||
static Category getCategoryByName(String name, double amount) {
|
||||
// Retrieve the icon and color using the getCategoryDetails method.
|
||||
final details = getCategoryDetails(name);
|
||||
// Construct and return the Category object.
|
||||
return Category(name, amount, details.colorCode, details.iconCode);
|
||||
}
|
||||
|
||||
/// Returns a list of all predefined category names.
|
||||
///
|
||||
/// Useful for generating UI elements like filter chips or dropdowns.
|
||||
/// DEPRECATED: Returns a list of all predefined category names (excluding placeholders).
|
||||
/// Might be less useful now that categories are fully dynamic.
|
||||
static List<String> getAllCategoryNames() {
|
||||
// Return the keys from the details map as a list.
|
||||
return _categoryDetails.keys.toList();
|
||||
// Return keys from _stringToIconData, excluding special ones
|
||||
return _stringToIconData.keys.where((key) =>
|
||||
key != 'help_outline' &&
|
||||
key != 'label_outline' &&
|
||||
key != 'attach_money' && // Exclude Income
|
||||
key != 'category_outlined' // Exclude Other/Default
|
||||
).toList();
|
||||
}
|
||||
|
||||
/// Converts a string icon name (like 'shopping_cart_outlined') to IconData.
|
||||
/// This map is crucial for displaying icons based on the string stored in the DB.
|
||||
static final Map<String, IconData> _stringToIconData = {
|
||||
// Existing Icons
|
||||
'shopping_cart_outlined': Icons.shopping_cart_outlined, // Groceries
|
||||
'subscriptions_outlined': Icons.subscriptions_outlined, // Subscriptions
|
||||
'restaurant_menu_outlined': Icons.restaurant_menu_outlined, // Restaurant
|
||||
'shopping_bag_outlined': Icons.shopping_bag_outlined, // Shopping
|
||||
'directions_bus_filled_outlined': Icons.directions_bus_filled_outlined, // Transport
|
||||
'flight_takeoff_outlined': Icons.flight_takeoff_outlined, // Travel
|
||||
'lightbulb_outline': Icons.lightbulb_outline, // Utilities (alternative)
|
||||
'local_hospital_outlined': Icons.local_hospital_outlined, // Health
|
||||
'movie_filter_outlined': Icons.movie_filter_outlined, // Entertainment
|
||||
'attach_money': Icons.attach_money, // Income (Special)
|
||||
'category_outlined': Icons.category_outlined, // Other/Default (Fallback)
|
||||
'label_outline': Icons.label_outline, // Placeholder (Internal)
|
||||
'help_outline': Icons.help_outline, // Placeholder/Error (Internal)
|
||||
'home_outlined': Icons.home_outlined, // Home/Rent/Mortgage/Utilities
|
||||
'pets_outlined': Icons.pets_outlined, // Pets
|
||||
'school_outlined': Icons.school_outlined, // Education/School
|
||||
'fitness_center_outlined': Icons.fitness_center_outlined, // Gym/Fitness
|
||||
'checkroom_outlined': Icons.checkroom_outlined, // Clothing
|
||||
'devices_other_outlined': Icons.devices_other_outlined, // Electronics
|
||||
'card_giftcard_outlined': Icons.card_giftcard_outlined, // Gifts
|
||||
'volunteer_activism_outlined': Icons.volunteer_activism_outlined, // Charity/Donations
|
||||
'receipt_long_outlined': Icons.receipt_long_outlined, // Bills/Receipts
|
||||
'savings_outlined': Icons.savings_outlined, // Savings/Investments
|
||||
'credit_card_outlined': Icons.credit_card_outlined, // Credit Card Payment
|
||||
'build_outlined': Icons.build_outlined, // Repairs/Maintenance
|
||||
'child_friendly_outlined': Icons.child_friendly_outlined, // Child Care
|
||||
'work_outline': Icons.work_outline, // Work related
|
||||
'book_outlined': Icons.book_outlined, // Books/Magazines
|
||||
'music_note_outlined': Icons.music_note_outlined, // Music
|
||||
'sports_esports_outlined': Icons.sports_esports_outlined, // Games/Hobbies
|
||||
'local_bar_outlined': Icons.local_bar_outlined, // Drinks/Bar
|
||||
'cake_outlined': Icons.cake_outlined, // Celebrations/Party
|
||||
'park_outlined': Icons.park_outlined, // Parks/Outdoors
|
||||
'science_outlined': Icons.science_outlined, // Science/Tech
|
||||
'palette_outlined': Icons.palette_outlined, // Art/Design
|
||||
'account_balance_outlined': Icons.account_balance_outlined, // Bank/Finance Fees
|
||||
'analytics_outlined': Icons.analytics_outlined, // Analysis/Reports (Maybe internal?)
|
||||
'apartment_outlined': Icons.apartment_outlined, // Rent/Mortgage (Alternative)
|
||||
'beach_access_outlined': Icons.beach_access_outlined, // Vacation/Beach
|
||||
'brush_outlined': Icons.brush_outlined, // Personal Care/Cosmetics
|
||||
'business_center_outlined': Icons.business_center_outlined, // Business Expenses
|
||||
'call_outlined': Icons.call_outlined, // Phone Bill
|
||||
'camera_alt_outlined': Icons.camera_alt_outlined, // Photography/Equipment
|
||||
'car_rental_outlined': Icons.car_rental_outlined, // Car Rental
|
||||
'car_repair_outlined': Icons.car_repair_outlined, // Car Repair
|
||||
'celebration_outlined': Icons.celebration_outlined, // Party/Events (Alternative)
|
||||
'computer_outlined': Icons.computer_outlined, // Computer/Software
|
||||
'construction_outlined': Icons.construction_outlined, // Home Improvement
|
||||
'cottage_outlined': Icons.cottage_outlined, // Vacation Home/Cottage
|
||||
'delivery_dining_outlined': Icons.delivery_dining_outlined, // Food Delivery
|
||||
'diamond_outlined': Icons.diamond_outlined, // Jewelry/Luxury
|
||||
'dry_cleaning_outlined': Icons.dry_cleaning_outlined, // Laundry/Dry Cleaning
|
||||
'electrical_services_outlined': Icons.electrical_services_outlined, // Electrician
|
||||
'emoji_events_outlined': Icons.emoji_events_outlined, // Awards/Competitions
|
||||
'fastfood_outlined': Icons.fastfood_outlined, // Fast Food
|
||||
'fax_outlined': Icons.fax_outlined, // Office Supplies (Maybe outdated?)
|
||||
'festival_outlined': Icons.festival_outlined, // Festivals/Events
|
||||
'fireplace_outlined': Icons.fireplace_outlined, // Heating/Fuel
|
||||
'flatware_outlined': Icons.flatware_outlined, // Kitchenware
|
||||
'gas_meter_outlined': Icons.gas_meter_outlined, // Gas Bill
|
||||
'gavel_outlined': Icons.gavel_outlined, // Legal Fees
|
||||
'grass_outlined': Icons.grass_outlined, // Gardening/Lawn Care
|
||||
'hardware_outlined': Icons.hardware_outlined, // Hardware Store
|
||||
'hearing_outlined': Icons.hearing_outlined, // Audio/Headphones
|
||||
'hiking_outlined': Icons.hiking_outlined, // Hiking/Outdoor Gear
|
||||
'icecream_outlined': Icons.icecream_outlined, // Ice Cream/Desserts
|
||||
'interests_outlined': Icons.interests_outlined, // Hobbies General
|
||||
'key_outlined': Icons.key_outlined, // Keys/Locks
|
||||
'liquor_outlined': Icons.liquor_outlined, // Alcohol
|
||||
'local_activity_outlined': Icons.local_activity_outlined, // Tickets/Events
|
||||
'local_atm_outlined': Icons.local_atm_outlined, // ATM Withdrawal (Maybe internal?)
|
||||
'local_convenience_store_outlined': Icons.local_convenience_store_outlined, // Convenience Store
|
||||
'local_florist_outlined': Icons.local_florist_outlined, // Flowers
|
||||
'local_gas_station_outlined': Icons.local_gas_station_outlined, // Gas/Fuel
|
||||
'local_laundry_service_outlined': Icons.local_laundry_service_outlined, // Laundry Service
|
||||
'local_mall_outlined': Icons.local_mall_outlined, // Mall Shopping
|
||||
'local_offer_outlined': Icons.local_offer_outlined, // Discounts/Sales/Coupons
|
||||
'local_parking_outlined': Icons.local_parking_outlined, // Parking Fees
|
||||
'local_pharmacy_outlined': Icons.local_pharmacy_outlined, // Pharmacy
|
||||
'local_pizza_outlined': Icons.local_pizza_outlined, // Pizza
|
||||
'local_shipping_outlined': Icons.local_shipping_outlined, // Shipping Costs
|
||||
'local_taxi_outlined': Icons.local_taxi_outlined, // Taxi/Rideshare
|
||||
'lunch_dining_outlined': Icons.lunch_dining_outlined, // Lunch
|
||||
'medication_outlined': Icons.medication_outlined, // Medication
|
||||
'museum_outlined': Icons.museum_outlined, // Museum/Exhibits
|
||||
'newspaper_outlined': Icons.newspaper_outlined, // Newspapers/Magazines
|
||||
'paid_outlined': Icons.paid_outlined, // Payments/Transfers (Maybe internal?)
|
||||
'pedal_bike_outlined': Icons.pedal_bike_outlined, // Cycling
|
||||
'plumbing_outlined': Icons.plumbing_outlined, // Plumber
|
||||
'ramen_dining_outlined': Icons.ramen_dining_outlined, // Noodles/Asian Food
|
||||
'recycling_outlined': Icons.recycling_outlined, // Recycling Fees
|
||||
'redeem_outlined': Icons.redeem_outlined, // Gifts Received/Redeemed (Maybe internal?)
|
||||
'request_quote_outlined': Icons.request_quote_outlined, // Invoices/Quotes (Maybe internal?)
|
||||
'roller_skating_outlined': Icons.roller_skating_outlined, // Skating
|
||||
'roofing_outlined': Icons.roofing_outlined, // Roofing Repair
|
||||
'room_service_outlined': Icons.room_service_outlined, // Hotel Service
|
||||
'shield_outlined': Icons.shield_outlined, // Insurance
|
||||
'skateboarding_outlined': Icons.skateboarding_outlined, // Skateboarding
|
||||
'smoking_rooms_outlined': Icons.smoking_rooms_outlined, // Tobacco
|
||||
'spa_outlined': Icons.spa_outlined, // Spa/Wellness
|
||||
'sports_bar_outlined': Icons.sports_bar_outlined, // Sports Bar
|
||||
'sports_basketball_outlined': Icons.sports_basketball_outlined, // Basketball
|
||||
'sports_football_outlined': Icons.sports_football_outlined, // Football
|
||||
'sports_golf_outlined': Icons.sports_golf_outlined, // Golf
|
||||
'sports_gymnastics_outlined': Icons.sports_gymnastics_outlined, // Gymnastics
|
||||
'sports_handball_outlined': Icons.sports_handball_outlined, // Handball
|
||||
'sports_hockey_outlined': Icons.sports_hockey_outlined, // Hockey
|
||||
'sports_kabaddi_outlined': Icons.sports_kabaddi_outlined, // Kabaddi
|
||||
'sports_mma_outlined': Icons.sports_mma_outlined, // MMA
|
||||
'sports_motorsports_outlined': Icons.sports_motorsports_outlined, // Motorsports
|
||||
'sports_soccer_outlined': Icons.sports_soccer_outlined, // Soccer
|
||||
'sports_tennis_outlined': Icons.sports_tennis_outlined, // Tennis
|
||||
'sports_volleyball_outlined': Icons.sports_volleyball_outlined, // Volleyball
|
||||
'stadium_outlined': Icons.stadium_outlined, // Stadium/Events
|
||||
'store_mall_directory_outlined': Icons.store_mall_directory_outlined, // Department Store
|
||||
'stroller_outlined': Icons.stroller_outlined, // Baby Supplies
|
||||
'subway_outlined': Icons.subway_outlined, // Subway/Metro
|
||||
'surfing_outlined': Icons.surfing_outlined, // Surfing
|
||||
'sync_alt_outlined': Icons.sync_alt_outlined, // Transfers (Maybe internal?)
|
||||
'theater_comedy_outlined': Icons.theater_comedy_outlined, // Comedy Club
|
||||
'theaters_outlined': Icons.theaters_outlined, // Cinema/Theater
|
||||
'toys_outlined': Icons.toys_outlined, // Toys
|
||||
'train_outlined': Icons.train_outlined, // Train Travel
|
||||
'tram_outlined': Icons.tram_outlined, // Tram
|
||||
'two_wheeler_outlined': Icons.two_wheeler_outlined, // Motorcycle/Scooter
|
||||
'vape_free_outlined': Icons.vape_free_outlined, // Vaping (quit)
|
||||
'vaping_rooms_outlined': Icons.vaping_rooms_outlined, // Vaping
|
||||
'videogame_asset_outlined': Icons.videogame_asset_outlined, // Video Games
|
||||
'water_drop_outlined': Icons.water_drop_outlined, // Water Bill
|
||||
'wifi_outlined': Icons.wifi_outlined, // Internet Bill
|
||||
'wine_bar_outlined': Icons.wine_bar_outlined, // Wine Bar
|
||||
};
|
||||
|
||||
/// Returns the IconData corresponding to the given icon name string.
|
||||
/// If the `iconName` is not found or is null/empty, returns a default fallback icon (`Icons.help_outline`).
|
||||
static IconData getIconFromString(String? iconName) {
|
||||
if (iconName == null || iconName.isEmpty) {
|
||||
return Icons.help_outline; // Default for null or empty
|
||||
}
|
||||
return _stringToIconData[iconName] ?? Icons.help_outline; // Return default if not found in map
|
||||
}
|
||||
|
||||
/// Returns a map of available icons for selection in UI (e.g., dropdowns, dialogs).
|
||||
/// Excludes placeholder/internal icons like 'help_outline', 'label_outline',
|
||||
/// 'category_outlined', and the special 'attach_money' (Income).
|
||||
static Map<String, IconData> getAvailableIcons() {
|
||||
final availableIcons = Map<String, IconData>.from(_stringToIconData);
|
||||
// Remove icons not intended for user selection as expense categories
|
||||
availableIcons.remove('help_outline'); // Internal fallback/error
|
||||
availableIcons.remove('label_outline'); // Internal placeholder
|
||||
availableIcons.remove('category_outlined'); // Internal generic fallback
|
||||
availableIcons.remove('attach_money'); // Reserved for Income type
|
||||
// Consider removing others if they represent internal states:
|
||||
// availableIcons.remove('sync_alt_outlined'); // Transfers?
|
||||
// availableIcons.remove('paid_outlined'); // Payments?
|
||||
// availableIcons.remove('local_atm_outlined'); // ATM?
|
||||
// availableIcons.remove('redeem_outlined'); // Gifts Received?
|
||||
// availableIcons.remove('request_quote_outlined'); // Invoices?
|
||||
// availableIcons.remove('analytics_outlined'); // Reports?
|
||||
return availableIcons;
|
||||
}
|
||||
|
||||
/// List of available colors for category selection in UI.
|
||||
static const List<Color> availableColors = [
|
||||
// Primary Colors (Good starting points)
|
||||
Colors.red, Colors.pink, Colors.purple, Colors.deepPurple,
|
||||
Colors.indigo, Colors.blue, Colors.lightBlue, Colors.cyan,
|
||||
Colors.teal, Colors.green, Colors.lightGreen, Colors.lime,
|
||||
Colors.yellow, Colors.amber, Colors.orange, Colors.deepOrange,
|
||||
Colors.brown, Colors.grey, Colors.blueGrey,
|
||||
|
||||
// Accent Colors (Brighter, use with care)
|
||||
Colors.redAccent, Colors.pinkAccent, Colors.purpleAccent, Colors.deepPurpleAccent,
|
||||
Colors.indigoAccent, Colors.blueAccent, Colors.lightBlueAccent, Colors.cyanAccent,
|
||||
Colors.tealAccent, Colors.greenAccent, Colors.limeAccent,
|
||||
Colors.yellowAccent, Colors.amberAccent, Colors.orangeAccent, Colors.deepOrangeAccent,
|
||||
|
||||
// Shade variations (More subtle options)
|
||||
/* Colors.red.shade300, Colors.pink.shade200, Colors.purple.shade300,
|
||||
Colors.indigo.shade300, Colors.blue.shade300, Colors.lightBlue.shade300,
|
||||
Colors.cyan.shade300, Colors.teal.shade300, Colors.green.shade300,
|
||||
Colors.lightGreen.shade300, Colors.lime.shade300, Colors.yellow.shade600, // Darker yellow
|
||||
Colors.amber.shade300, Colors.orange.shade300, Colors.deepOrange.shade300,
|
||||
Colors.brown.shade300, Colors.grey.shade400, Colors.blueGrey.shade300,*/
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../database/database.dart' as db; // Import database with prefix 'db'
|
||||
import '../utils/category_utils.dart'; // Import CategoryUtils
|
||||
|
||||
class AddCategoryDialog extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
|
||||
const AddCategoryDialog({Key? key, required this.database}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddCategoryDialog> createState() => _AddCategoryDialogState();
|
||||
}
|
||||
|
||||
class _AddCategoryDialogState extends State<AddCategoryDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
// State for selected icon
|
||||
String _selectedIconName = 'label_outline'; // Default icon name (string)
|
||||
IconData _selectedIconData = Icons.label_outline; // Default icon data
|
||||
// State for selected color
|
||||
Color _selectedColor = CategoryUtils.availableColors[9]; // Default to green
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize icon data based on the default name
|
||||
_selectedIconData = CategoryUtils.getIconFromString(_selectedIconName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// --- Function to show the icon picker dialog ---
|
||||
Future<void> _showIconPicker() async {
|
||||
final Map<String, IconData> availableIcons = CategoryUtils.getAvailableIcons();
|
||||
final List<String> iconNames = availableIcons.keys.toList();
|
||||
final List<IconData> iconDatas = availableIcons.values.toList();
|
||||
print("Number of available icons: ${availableIcons.length}"); // Debug print
|
||||
|
||||
final String? chosenIconName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Выберите иконку'),
|
||||
contentPadding: const EdgeInsets.all(10.0), // Adjust padding
|
||||
content: SizedBox( // Constrain the size of the dialog content
|
||||
width: double.maxFinite, // Use maximum width available
|
||||
height: 300, // <--- ЗАДАЕМ ВЫСОТУ ДЛЯ ОБЛАСТИ ПРОКРУТКИ
|
||||
child: GridView.builder(
|
||||
// shrinkWrap: true, // <--- УБИРАЕМ SHRINKWRAP
|
||||
itemCount: iconNames.length,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5, // Adjust number of columns
|
||||
crossAxisSpacing: 10.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final bool isSelected = _selectedIconName == iconNames[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context, iconNames[index]); // Return the selected icon name (String)
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8.0), // Ripple effect matches border
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary // Highlight selected
|
||||
: Colors.grey.withOpacity(0.3), // Subtle border for all
|
||||
width: isSelected ? 2.0 : 1.0, // Thicker border if selected
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary.withOpacity(0.1) : null,
|
||||
),
|
||||
child: Icon(
|
||||
iconDatas[index],
|
||||
size: 30.0, // Adjust icon size
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white70
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null), // Close without selection
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Update state if an icon was chosen
|
||||
if (chosenIconName != null) {
|
||||
setState(() {
|
||||
_selectedIconName = chosenIconName;
|
||||
_selectedIconData = CategoryUtils.getIconFromString(chosenIconName);
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- End of Icon Picker Function ---
|
||||
|
||||
|
||||
Future<void> _saveCategory() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final name = _nameController.text;
|
||||
// Use the selected icon name
|
||||
final icon = _selectedIconName;
|
||||
// Use the selected color value
|
||||
final color = _selectedColor.value;
|
||||
|
||||
final newCategoryCompanion = db.CategoriesCompanion(
|
||||
name: Value(name),
|
||||
icon: Value(icon), // Use selected icon name
|
||||
color: Value(color), // Use selected color value
|
||||
);
|
||||
|
||||
try {
|
||||
// TODO: Add check for duplicate category name before inserting (more robustly)
|
||||
final newCategoryId = await widget.database.addCategory(newCategoryCompanion);
|
||||
final newCategory = await widget.database.getCategoryById(newCategoryId);
|
||||
if (mounted) {
|
||||
Navigator.pop(context, newCategory); // Return the newly created CategoryDb object
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error adding category: $e');
|
||||
// Handle potential duplicate name error from database (depends on DB constraints)
|
||||
String errorMessage = 'Произошла ошибка при добавлении категории.';
|
||||
if (e.toString().toLowerCase().contains('unique constraint failed')) {
|
||||
errorMessage = 'Категория с таким именем уже существует.';
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Determine icon color based on the selected background color's brightness
|
||||
final iconColor = ThemeData.estimateBrightnessForColor(_selectedColor) == Brightness.dark
|
||||
? Colors.white70
|
||||
: Colors.black87;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Создать категорию'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // Align children to the start
|
||||
children: [
|
||||
// --- Icon and Name Row ---
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// --- Icon Picker ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 10.0), // Add padding
|
||||
child: InkWell(
|
||||
onTap: _showIconPicker, // Show picker on tap
|
||||
customBorder: const CircleBorder(), // Make ripple circular
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: _selectedColor, // Use selected color for background
|
||||
child: Icon(
|
||||
_selectedIconData, // Display selected icon
|
||||
color: iconColor, // Adjust icon color based on background
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// --- Name Field ---
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название категории',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Пожалуйста, введите название';
|
||||
}
|
||||
// Basic length check
|
||||
if (value.length > 50) {
|
||||
return 'Название слишком длинное';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20), // Space before color picker
|
||||
|
||||
// --- Color Picker ---
|
||||
const Text('Выберите цвет:', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap( // Use Wrap for horizontal layout with wrapping
|
||||
spacing: 8.0, // Horizontal space between circles
|
||||
runSpacing: 8.0, // Vertical space between rows
|
||||
children: CategoryUtils.availableColors.map((color) {
|
||||
final bool isSelected = _selectedColor == color;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color; // Update selected color on tap
|
||||
});
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Container(
|
||||
width: 32, // Size of the color circle
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? theme.colorScheme.onSurface // Highlight selected
|
||||
: theme.dividerColor, // Border for others
|
||||
width: isSelected ? 3.0 : 1.0,
|
||||
),
|
||||
boxShadow: isSelected ? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
)
|
||||
] : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16), // Add some space at the bottom
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null), // Return null if cancelled
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _saveCategory,
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import '../database/database.dart' as db;
|
||||
import '../utils/category_utils.dart';
|
||||
|
||||
class EditCategoryDialog extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
final db.CategoryDb? categoryToEdit; // null если добавляем новую
|
||||
|
||||
const EditCategoryDialog({
|
||||
Key? key,
|
||||
required this.database,
|
||||
this.categoryToEdit,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EditCategoryDialog> createState() => _EditCategoryDialogState();
|
||||
}
|
||||
|
||||
class _EditCategoryDialogState extends State<EditCategoryDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _nameController;
|
||||
String? _selectedIconName;
|
||||
Color? _selectedColor;
|
||||
|
||||
bool get _isEditing => widget.categoryToEdit != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.categoryToEdit?.name ?? '');
|
||||
// Ensure the initial icon exists in the available list, otherwise pick the first
|
||||
_selectedIconName = widget.categoryToEdit?.icon;
|
||||
if (_selectedIconName == null || !CategoryUtils.getAvailableIcons().containsKey(_selectedIconName)) {
|
||||
_selectedIconName = CategoryUtils.getAvailableIcons().keys.first;
|
||||
}
|
||||
|
||||
// Инициализируем цвет.
|
||||
// Если редактируем существующую категорию, используем ее цвет из БД.
|
||||
// Если добавляем новую, используем первый цвет из доступных.
|
||||
if (_isEditing) {
|
||||
_selectedColor = Color(widget.categoryToEdit!.color);
|
||||
} else {
|
||||
_selectedColor = CategoryUtils.availableColors.first;
|
||||
}
|
||||
|
||||
// Убедимся, что _selectedColor не null после инициализации
|
||||
// (это должно быть гарантировано логикой выше, но для безопасности)
|
||||
_selectedColor ??= CategoryUtils.availableColors.first;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveCategory() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final name = _nameController.text.trim();
|
||||
final icon = _selectedIconName;
|
||||
final color = _selectedColor;
|
||||
|
||||
if (icon == null || color == null) {
|
||||
// This should not happen due to initialization logic, but check anyway
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Пожалуйста, выберите иконку и цвет'),
|
||||
backgroundColor: Colors.orange,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка на уникальность имени (кроме случая редактирования той же категории)
|
||||
// Используем case-insensitive сравнение
|
||||
final existingCategories = await widget.database.watchAllCategoriesDb().first;
|
||||
final isNameTaken = existingCategories.any((c) =>
|
||||
c.name.toLowerCase() == name.toLowerCase() &&
|
||||
(!_isEditing || c.id != widget.categoryToEdit!.id)); // Проверяем ID только при редактировании
|
||||
|
||||
if (isNameTaken) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Категория с именем "$name" уже существует.'),
|
||||
backgroundColor: Colors.orange,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Запрещаем имя 'Income' (case-insensitive)
|
||||
if (name.toLowerCase() == 'income') {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Имя "Income" зарезервировано.'),
|
||||
backgroundColor: Colors.orange,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final companion = db.CategoriesCompanion(
|
||||
id: _isEditing ? Value(widget.categoryToEdit!.id) : const Value.absent(),
|
||||
name: Value(name),
|
||||
icon: Value(icon),
|
||||
color: Value(color.value),
|
||||
);
|
||||
|
||||
try {
|
||||
if (_isEditing) {
|
||||
await widget.database.updateCategory(companion);
|
||||
} else {
|
||||
await widget.database.addCategory(companion);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true); // Возвращаем true при успехе
|
||||
} catch (e) {
|
||||
print('Error saving category: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка сохранения категории: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final availableIcons = CategoryUtils.getAvailableIcons(); // Get filtered icons
|
||||
|
||||
// Объединяем цвет текущей категории (если редактируем) с доступными цветами
|
||||
// для отображения в палитре. Это нужно, чтобы текущий цвет был виден,
|
||||
// даже если его нет в стандартном списке.
|
||||
final List<Color> displayedColors = List.from(CategoryUtils.availableColors);
|
||||
if (_isEditing && _selectedColor != null && !CategoryUtils.availableColors.contains(_selectedColor)) {
|
||||
// Добавляем цвет текущей категории в начало списка для отображения
|
||||
displayedColors.insert(0, _selectedColor!);
|
||||
}
|
||||
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(_isEditing ? 'Редактировать категорию' : 'Добавить категорию'),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24.0, 20.0, 24.0, 0.0), // Adjust padding
|
||||
content: SizedBox( // Constrain width for better appearance on large screens
|
||||
width: MediaQuery.of(context).size.width * 0.8, // Example width constraint
|
||||
child: SingleChildScrollView( // Позволяет прокручивать, если не помещается
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // Align labels to start
|
||||
children: [
|
||||
// --- Поле Имя ---
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Название категории',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Введите название';
|
||||
}
|
||||
if (value.trim().toLowerCase() == 'income') {
|
||||
return 'Имя "Income" зарезервировано';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// --- Выбор Иконки ---
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedIconName,
|
||||
isExpanded: true, // Allow dropdown to expand
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Иконка',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
|
||||
),
|
||||
items: availableIcons.entries.map((entry) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: entry.key,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(entry.value, color: _selectedColor ?? theme.colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
// Отображаем имя иконки, убирая '_outlined' и делая первую букву заглавной
|
||||
Text(entry.key.replaceAll('_outlined', '').replaceAll('_', ' ').capitalizeFirst()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedIconName = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
validator: (value) => value == null ? 'Выберите иконку' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// --- Выбор Цвета ---
|
||||
Text('Цвет категории:', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 10),
|
||||
Wrap( // Используем Wrap для отображения цветов в несколько рядов
|
||||
spacing: 10.0, // Горизонтальный отступ
|
||||
runSpacing: 10.0, // Вертикальный отступ
|
||||
children: displayedColors.map((color) { // Используем объединенный список цветов
|
||||
final isSelected = _selectedColor == color;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? (isDark ? Colors.white70 : Colors.black87)
|
||||
: theme.dividerColor.withOpacity(0.5), // Subtle border for unselected
|
||||
width: isSelected ? 2.5 : 1.0,
|
||||
),
|
||||
boxShadow: isSelected ? [
|
||||
BoxShadow(
|
||||
color: color.withOpacity(0.5),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
] : [],
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: ThemeData.estimateBrightnessForColor(color) == Brightness.dark ? Colors.white : Colors.black, size: 20)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24), // Add space before actions
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(24.0, 0.0, 24.0, 16.0), // Adjust actions padding
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false), // Возвращаем false при отмене
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save_alt_rounded),
|
||||
onPressed: _saveCategory,
|
||||
label: const Text('Сохранить'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper extension for capitalizing first letter
|
||||
extension StringExtension on String {
|
||||
String capitalizeFirst() {
|
||||
if (isEmpty) return this;
|
||||
return "${this[0].toUpperCase()}${substring(1)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../database/database.dart' as db;
|
||||
import '../utils/category_utils.dart'; // For icon and color utilities
|
||||
|
||||
class EditTransactionDialog extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
final db.Transaction transaction; // The transaction to edit
|
||||
final Stream<List<db.CategoryDb>> categoriesStream; // Stream of available categories
|
||||
|
||||
const EditTransactionDialog({
|
||||
Key? key,
|
||||
required this.database,
|
||||
required this.transaction,
|
||||
required this.categoriesStream,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EditTransactionDialog> createState() => _EditTransactionDialogState();
|
||||
}
|
||||
|
||||
class _EditTransactionDialogState extends State<EditTransactionDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _amountController;
|
||||
late TextEditingController _merchantController;
|
||||
late String? _selectedCategoryName; // Can be null for Income
|
||||
late DateTime _selectedDate;
|
||||
late db.TransactionType _selectedType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize controllers and state with existing transaction data
|
||||
_amountController = TextEditingController(text: widget.transaction.amount.toString());
|
||||
_merchantController = TextEditingController(text: widget.transaction.merchant);
|
||||
_selectedDate = widget.transaction.date;
|
||||
_selectedType = widget.transaction.type == 'income' ? db.TransactionType.income : db.TransactionType.expense;
|
||||
|
||||
// Set initial category name based on transaction type
|
||||
if (_selectedType == db.TransactionType.expense) {
|
||||
_selectedCategoryName = widget.transaction.categoryName;
|
||||
} else {
|
||||
_selectedCategoryName = null; // Income doesn't have a selectable category
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_merchantController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Function to show the date and time pickers
|
||||
Future<void> _selectDateTime(BuildContext context) async {
|
||||
// 1. Pick Date
|
||||
final DateTime? pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
|
||||
if (pickedDate != null) {
|
||||
// If date was picked, proceed to pick time
|
||||
// 2. Pick Time
|
||||
final TimeOfDay? pickedTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_selectedDate),
|
||||
);
|
||||
|
||||
if (pickedTime != null) {
|
||||
// If time was also picked, combine date and time and update state
|
||||
setState(() {
|
||||
_selectedDate = DateTime(
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
pickedTime.hour,
|
||||
pickedTime.minute,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// If only date was picked, update state with the picked date and existing time
|
||||
setState(() {
|
||||
_selectedDate = DateTime(
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
_selectedDate.hour, // Keep existing hour
|
||||
_selectedDate.minute, // Keep existing minute
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle form submission (Update)
|
||||
void _updateTransaction() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final amount = double.tryParse(_amountController.text);
|
||||
if (amount == null || amount <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Пожалуйста, введите корректную положительную сумму.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
String categoryToSave;
|
||||
String typeString = _selectedType == db.TransactionType.income ? 'income' : 'expense';
|
||||
|
||||
if (_selectedType == db.TransactionType.income) {
|
||||
categoryToSave = 'Income'; // Fixed category for income
|
||||
} else {
|
||||
if (_selectedCategoryName == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Пожалуйста, выберите категорию для расхода.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
categoryToSave = _selectedCategoryName!;
|
||||
}
|
||||
|
||||
// Create the updated transaction companion, including userId
|
||||
final updatedTransaction = db.TransactionsCompanion(
|
||||
id: Value(widget.transaction.id), // Include the ID for update
|
||||
categoryName: Value(categoryToSave),
|
||||
amount: Value(amount),
|
||||
date: Value(_selectedDate),
|
||||
merchant: Value(_merchantController.text),
|
||||
type: Value(typeString),
|
||||
);
|
||||
|
||||
try {
|
||||
// Update transaction in the database
|
||||
final success = await widget.database.updateTransaction(updatedTransaction);
|
||||
|
||||
if (success) {
|
||||
// Close the dialog and return the updated transaction
|
||||
if (mounted) Navigator.of(context).pop(widget.transaction.copyWith( // Return a copy with updated values
|
||||
categoryName: categoryToSave,
|
||||
amount: amount,
|
||||
date: _selectedDate,
|
||||
merchant: _merchantController.text,
|
||||
type: typeString,
|
||||
));
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Не удалось обновить транзакцию.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error updating transaction: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка при обновлении транзакции: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bool isIncome = _selectedType == db.TransactionType.income;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Редактировать транзакцию'),
|
||||
content: SingleChildScrollView( // Use SingleChildScrollView for content
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// --- Transaction Type Selector ---
|
||||
Center(
|
||||
child: ToggleButtons(
|
||||
isSelected: [!isIncome, isIncome],
|
||||
onPressed: (int index) {
|
||||
setState(() {
|
||||
_selectedType = index == 0 ? db.TransactionType.expense : db.TransactionType.income;
|
||||
// Reset category selection if switching to income
|
||||
if (_selectedType == db.TransactionType.income) {
|
||||
_selectedCategoryName = null;
|
||||
} else {
|
||||
// If switching to expense, try to select the first expense category
|
||||
// This relies on the StreamBuilder below to update the dropdown
|
||||
// and potentially set a default if _selectedCategoryName is null.
|
||||
}
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// ИЗМЕНЕНО: Уменьшена минимальная ширина кнопок
|
||||
constraints: BoxConstraints(minWidth: (MediaQuery.of(context).size.width - 160) / 2, minHeight: 40), // Adjusted width for dialog
|
||||
selectedColor: Colors.white,
|
||||
fillColor: isIncome ? Colors.green.shade400 : Colors.red.shade400,
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
selectedBorderColor: isIncome ? Colors.green.shade600 : Colors.red.shade600,
|
||||
borderColor: isDark ? Colors.grey.shade600 : Colors.grey.shade400,
|
||||
children: const <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [ Icon(Icons.arrow_upward_rounded, size: 18), SizedBox(width: 8), Text('Расход'), ],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [ Icon(Icons.arrow_downward_rounded, size: 18), SizedBox(width: 8), Text('Доход'), ],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// --- Amount Field ---
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Сумма',
|
||||
prefixIcon: Icon(Icons.attach_money, color: isIncome ? Colors.green : theme.colorScheme.primary),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Пожалуйста, введите сумму';
|
||||
}
|
||||
if (double.tryParse(value) == null || double.parse(value) <= 0) {
|
||||
return 'Пожалуйста, введите корректное положительное число';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Category Dropdown (Only for Expenses, uses StreamBuilder) ---
|
||||
if (!isIncome)
|
||||
StreamBuilder<List<db.CategoryDb>>(
|
||||
stream: widget.categoriesStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Text('Ошибка загрузки категорий: ${snapshot.error}');
|
||||
}
|
||||
|
||||
final categoriesFromDb = snapshot.data ?? [];
|
||||
// Filter out 'Income' category for the dropdown
|
||||
final expenseCategories = categoriesFromDb.where((c) => c.name != 'Income').toList();
|
||||
|
||||
// Ensure _selectedCategoryName is valid or reset it
|
||||
if (_selectedCategoryName != null && !expenseCategories.any((c) => c.name == _selectedCategoryName)) {
|
||||
_selectedCategoryName = null; // Reset if selected category is no longer valid
|
||||
}
|
||||
// Set default selection if nothing is selected and list is not empty
|
||||
// This handles the case when switching from Income to Expense
|
||||
if (_selectedCategoryName == null && expenseCategories.isNotEmpty) {
|
||||
// Use WidgetsBinding to schedule state update after build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) { // Check if widget is still mounted
|
||||
setState(() {
|
||||
_selectedCategoryName = expenseCategories[0].name;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
value: _selectedCategoryName,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Категория',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
items: expenseCategories.map((db.CategoryDb category) {
|
||||
final iconData = CategoryUtils.getIconFromString(category.icon);
|
||||
final colorData = Color(category.color);
|
||||
return DropdownMenuItem<String>(
|
||||
value: category.name,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(iconData, color: colorData, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(category.name),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
setState(() {
|
||||
_selectedCategoryName = newValue;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (_selectedType == db.TransactionType.expense && value == null) {
|
||||
return 'Пожалуйста, выберите категорию';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!isIncome) const SizedBox(height: 16),
|
||||
|
||||
// --- Date and Time Picker ---
|
||||
InkWell(
|
||||
onTap: () => _selectDateTime(context),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Дата и время',
|
||||
prefixIcon: const Icon(Icons.calendar_today_outlined),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
),
|
||||
child: Text(
|
||||
DateFormat.yMMMd().add_jm().format(_selectedDate),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Merchant / Source Field ---
|
||||
TextFormField(
|
||||
controller: _merchantController,
|
||||
decoration: InputDecoration(
|
||||
labelText: isIncome ? 'Источник' : 'Продавец / Магазин',
|
||||
prefixIcon: Icon(isIncome ? Icons.source_outlined : Icons.storefront_outlined),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), // Close dialog
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _updateTransaction, // Call update function
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,36 @@ import 'package:intl/intl.dart'; // For number formatting
|
||||
|
||||
import '../models/category.dart'; // Keep using the Category model for UI structure
|
||||
|
||||
class SpendingPieChart extends StatelessWidget {
|
||||
// Changed to StatefulWidget to manage its own selection state
|
||||
class SpendingPieChart extends StatefulWidget {
|
||||
final List<Category> categories; // Expect List<Category> from database calculation
|
||||
final double totalExpenses;
|
||||
final int selectedPieIndex;
|
||||
final Function(int) onSelectPieCategory; // Callback when a slice is selected/deselected
|
||||
final Animation<double> animation; // For fade/scale animation
|
||||
|
||||
const SpendingPieChart({
|
||||
Key? key,
|
||||
required this.categories,
|
||||
required this.totalExpenses,
|
||||
required this.selectedPieIndex,
|
||||
required this.onSelectPieCategory,
|
||||
required this.animation,
|
||||
// Removed selectedPieIndex and onSelectPieCategory
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SpendingPieChart> createState() => _SpendingPieChartState();
|
||||
}
|
||||
|
||||
class _SpendingPieChartState extends State<SpendingPieChart> {
|
||||
// State for the selected index is now managed internally
|
||||
int _selectedPieIndex = -1;
|
||||
|
||||
// Handles selection logic internally
|
||||
void _handlePieTap(int index) {
|
||||
setState(() {
|
||||
// If the same index is selected, deselect (-1), otherwise select the new index
|
||||
_selectedPieIndex = (_selectedPieIndex == index) ? -1 : index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
@@ -27,11 +41,11 @@ class SpendingPieChart extends StatelessWidget {
|
||||
final currencyFormatter = NumberFormat.currency(locale: 'en_US', symbol: '\$'); // Or your preferred locale/symbol
|
||||
|
||||
// Handle the case where there are no categories to display
|
||||
if (categories.isEmpty) {
|
||||
if (widget.categories.isEmpty) {
|
||||
return AnimatedBuilder( // Still use animation for consistency
|
||||
animation: animation,
|
||||
animation: widget.animation,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: animation.value,
|
||||
opacity: widget.animation.value,
|
||||
child: Container(
|
||||
height: 230, // Maintain similar height to the chart version
|
||||
alignment: Alignment.center,
|
||||
@@ -48,12 +62,12 @@ class SpendingPieChart extends StatelessWidget {
|
||||
|
||||
// Use AnimatedBuilder to apply the fade/scale animation
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
animation: widget.animation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: animation.value, // Apply scale animation
|
||||
scale: widget.animation.value, // Apply scale animation
|
||||
child: Opacity(
|
||||
opacity: animation.value, // Apply fade animation
|
||||
opacity: widget.animation.value, // Apply fade animation
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0), // Reduced vertical padding
|
||||
height: 230, // Fixed height for the chart and legend area
|
||||
@@ -76,13 +90,13 @@ class SpendingPieChart extends StatelessWidget {
|
||||
if (section != null) {
|
||||
// Tap occurred ON a section
|
||||
final touchedIndex = section.touchedSectionIndex;
|
||||
// Toggle selection: if tapped section is already selected, deselect (-1), otherwise select it.
|
||||
onSelectPieCategory(touchedIndex == selectedPieIndex ? -1 : touchedIndex);
|
||||
// Use internal handler to update state
|
||||
_handlePieTap(touchedIndex);
|
||||
} else {
|
||||
// Tap occurred OUTSIDE any section
|
||||
// Deselect if something was selected
|
||||
if (selectedPieIndex != -1) {
|
||||
onSelectPieCategory(-1);
|
||||
if (_selectedPieIndex != -1) {
|
||||
_handlePieTap(-1); // Pass -1 to deselect
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +113,7 @@ class SpendingPieChart extends StatelessWidget {
|
||||
swapAnimationCurve: Curves.easeInOut,
|
||||
),
|
||||
// --- Center Text (Displayed when a slice is selected) ---
|
||||
if (selectedPieIndex != -1)
|
||||
if (_selectedPieIndex != -1)
|
||||
_buildCenterText(context, currencyFormatter)
|
||||
else // Optional: Display total or default text when nothing is selected
|
||||
_buildDefaultCenterText(context, currencyFormatter),
|
||||
@@ -113,7 +127,7 @@ class SpendingPieChart extends StatelessWidget {
|
||||
flex: 4, // Allocate space for the legend
|
||||
// Use ListView for scrollable legend if many categories
|
||||
child: ListView.builder(
|
||||
itemCount: categories.length,
|
||||
itemCount: widget.categories.length,
|
||||
padding: const EdgeInsets.only(right: 8), // Padding for legend items
|
||||
itemBuilder: (context, index) => _buildPieLegendItem(context, index),
|
||||
),
|
||||
@@ -131,11 +145,11 @@ class SpendingPieChart extends StatelessWidget {
|
||||
Widget _buildCenterText(BuildContext context, NumberFormat formatter) {
|
||||
final theme = Theme.of(context);
|
||||
// Check if selectedPieIndex is valid before accessing categories
|
||||
if (selectedPieIndex < 0 || selectedPieIndex >= categories.length) {
|
||||
if (_selectedPieIndex < 0 || _selectedPieIndex >= widget.categories.length) {
|
||||
// Return an empty container or default text if index is invalid
|
||||
return _buildDefaultCenterText(context, formatter);
|
||||
}
|
||||
final selectedCategory = categories[selectedPieIndex];
|
||||
final selectedCategory = widget.categories[_selectedPieIndex];
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -177,7 +191,7 @@ class SpendingPieChart extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatter.format(totalExpenses), // Display total expenses
|
||||
formatter.format(widget.totalExpenses), // Display total expenses
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.textTheme.bodyLarge?.color // Use default text color
|
||||
@@ -193,18 +207,18 @@ class SpendingPieChart extends StatelessWidget {
|
||||
Widget _buildPieLegendItem(BuildContext context, int index) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = index == selectedPieIndex; // Check if this item is selected
|
||||
final isSelected = index == _selectedPieIndex; // Check if this item is selected using internal state
|
||||
// Check if index is valid before accessing categories
|
||||
if (index < 0 || index >= categories.length) {
|
||||
if (index < 0 || index >= widget.categories.length) {
|
||||
return const SizedBox.shrink(); // Return empty if index is invalid
|
||||
}
|
||||
final category = categories[index];
|
||||
final category = widget.categories[index];
|
||||
// Calculate percentage, handle totalExpenses being zero
|
||||
final percentage = totalExpenses > 0 ? (category.amount / totalExpenses * 100) : 0.0;
|
||||
final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0.0;
|
||||
|
||||
// Use InkWell for tap feedback and GestureDetector for tap logic
|
||||
return InkWell(
|
||||
onTap: () => onSelectPieCategory(isSelected ? -1 : index), // Toggle selection on tap
|
||||
onTap: () => _handlePieTap(index), // Use internal handler on tap
|
||||
borderRadius: BorderRadius.circular(8), // Match border radius
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200), // Animation for selection change
|
||||
@@ -264,21 +278,21 @@ class SpendingPieChart extends StatelessWidget {
|
||||
List<PieChartSectionData> _generatePieSections(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return List.generate(categories.length, (i) {
|
||||
return List.generate(widget.categories.length, (i) {
|
||||
// Check if index is valid before accessing categories
|
||||
if (i < 0 || i >= categories.length) {
|
||||
if (i < 0 || i >= widget.categories.length) {
|
||||
// This should ideally not happen if List.generate is used correctly,
|
||||
// but adding a safeguard.
|
||||
return PieChartSectionData(); // Return an empty section
|
||||
}
|
||||
final isTouched = i == selectedPieIndex; // Check if this slice is selected
|
||||
final isTouched = i == _selectedPieIndex; // Check if this slice is selected using internal state
|
||||
// Make selected slice slightly larger
|
||||
final double radius = isTouched ? 65 : 55;
|
||||
// Make title font slightly larger when selected
|
||||
final double titleFontSize = isTouched ? 14 : 12;
|
||||
final category = categories[i];
|
||||
final category = widget.categories[i];
|
||||
// Calculate percentage for the title
|
||||
final percentage = totalExpenses > 0 ? (category.amount / totalExpenses * 100) : 0;
|
||||
final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0;
|
||||
|
||||
return PieChartSectionData(
|
||||
color: category.colorCode.withOpacity(isDark ? 0.85 : 1.0), // Use category color
|
||||
|
||||
@@ -1,122 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../database/database.dart' as db; // Import database with prefix 'db'
|
||||
import '../models/transaction_record.dart';
|
||||
import '../utils/category_utils.dart'; // Import category utils
|
||||
import 'package:intl/intl.dart'; // For date formatting
|
||||
|
||||
import '../models/transaction_record.dart'; // Use the model class
|
||||
|
||||
class TransactionListItem extends StatelessWidget {
|
||||
final TransactionRecord transaction; // Use the Drift-generated Transaction class
|
||||
final Animation<double> animation; // Keep animation for potential future use
|
||||
final TransactionRecord transaction; // Use the model class
|
||||
final VoidCallback? onEdit; // Callback for edit action
|
||||
final VoidCallback? onDelete; // Callback for delete action
|
||||
|
||||
const TransactionListItem({
|
||||
Key? key,
|
||||
required this.transaction,
|
||||
required this.animation,
|
||||
this.onEdit, // Make callbacks optional
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
// Function to show the options menu
|
||||
void _showOptionsMenu(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea( // Use SafeArea to avoid system UI
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // Take minimum space
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: const Text('Редактировать'),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close the bottom sheet
|
||||
onEdit?.call(); // Call the edit callback if it exists
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: const Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close the bottom sheet
|
||||
onDelete?.call(); // Call the delete callback if it exists
|
||||
},
|
||||
),
|
||||
// Optional: Add a Cancel button
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cancel_outlined),
|
||||
title: const Text('Отмена'),
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isExpense = transaction.type == 'expense';
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Get category details (icon, color) using the utility
|
||||
final categoryDetails = CategoryUtils.getCategoryDetails(transaction.categoryName);
|
||||
|
||||
// Format the date and time using intl package
|
||||
final dateFormatter = DateFormat.MMMd(); // e.g., Sep 10
|
||||
final timeFormatter = DateFormat.jm(); // e.g., 5:08 PM
|
||||
|
||||
// Determine if the date is today, yesterday, or another day
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = DateTime(now.year, now.month, now.day - 1);
|
||||
final transactionDay = DateTime(transaction.date.year, transaction.date.month, transaction.date.day);
|
||||
|
||||
String displayDate;
|
||||
if (transactionDay == today) {
|
||||
displayDate = 'Today, ${timeFormatter.format(transaction.date)}';
|
||||
} else if (transactionDay == yesterday) {
|
||||
displayDate = 'Yesterday, ${timeFormatter.format(transaction.date)}';
|
||||
} else {
|
||||
// Format for other dates (e.g., "Sep 10, 5:08 PM")
|
||||
displayDate = '${dateFormatter.format(transaction.date)}, ${timeFormatter.format(transaction.date)}';
|
||||
}
|
||||
|
||||
// Determine text colors based on theme
|
||||
Color primaryTextColor = theme.textTheme.bodyLarge?.color ?? (isDark ? Colors.white : Colors.black87);
|
||||
Color secondaryTextColor = theme.textTheme.bodyMedium?.color ?? (isDark ? Colors.white70 : Colors.grey.shade600);
|
||||
Color amountColor = isDark ? Colors.red.shade200 : Colors.red.shade700; // Expense color
|
||||
|
||||
// Use FadeTransition for item appearance (works with ListView.builder)
|
||||
return FadeTransition(
|
||||
opacity: animation, // Apply fade animation
|
||||
child: InkWell( // Make the item tappable
|
||||
onTap: () {
|
||||
// TODO: Implement navigation to transaction details screen or edit action
|
||||
print('Tapped transaction: ${transaction.id}');
|
||||
},
|
||||
// Wrap the ListTile in an InkWell to handle long press
|
||||
return InkWell(
|
||||
onLongPress: () => _showOptionsMenu(context), // Show menu on long press
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0), // Consistent padding
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), // Adjusted padding
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon container with category color/icon
|
||||
// Category Icon (only for expenses)
|
||||
if (isExpense && transaction.category != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: categoryDetails.colorCode.withOpacity(isDark ? 0.25 : 0.15), // Use category color with opacity
|
||||
borderRadius: BorderRadius.circular(12), // Rounded corners
|
||||
color: transaction.category!.colorCode.withOpacity(0.1), // Use category color
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
categoryDetails.iconCode, // Use category icon
|
||||
color: categoryDetails.colorCode, // Use category color for icon
|
||||
size: 20, // Icon size
|
||||
transaction.category!.iconCode, // Use category icon
|
||||
color: transaction.category!.colorCode,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
else if (!isExpense) // Icon for Income
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100, // Specific color for income icon background
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.attach_money_outlined, // Specific icon for income
|
||||
color: Colors.green.shade700,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12), // Spacing
|
||||
const SizedBox(width: 16), // Space between icon and text
|
||||
|
||||
// Transaction details (Merchant/Category and Date/Time)
|
||||
// Transaction Details (Category/Merchant, Date)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Display Merchant if available, otherwise Category Name
|
||||
Text(
|
||||
transaction.merchant.isNotEmpty ? transaction.merchant : transaction.categoryName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500, // Medium weight for primary text
|
||||
color: primaryTextColor,
|
||||
),
|
||||
maxLines: 1, // Prevent wrapping
|
||||
overflow: TextOverflow.ellipsis, // Handle long text
|
||||
),
|
||||
const SizedBox(height: 4), // Spacing between lines
|
||||
// Display formatted date/time
|
||||
Text(
|
||||
displayDate,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: secondaryTextColor, // Lighter color for secondary text
|
||||
fontSize: 12, // Smaller font size
|
||||
isExpense ? transaction.merchant : transaction.merchant, // Display merchant for both for now
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500, // Medium weight
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
overflow: TextOverflow.ellipsis, // Prevent overflow
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12), // Spacing before amount
|
||||
|
||||
// Transaction Amount
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
// Format amount as currency (negative for expense)
|
||||
NumberFormat.currency(symbol: '-\$', decimalDigits: 2).format(transaction.amount),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600, // Bold weight for amount
|
||||
color: amountColor, // Use expense color
|
||||
// Display category name for expense, or 'Income' for income
|
||||
isExpense ? transaction.category?.name ?? 'Unknown Category' : 'Income',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), // Subtle color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Amount and Date
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${isExpense ? '-' : '+'} \$${NumberFormat.currency(symbol: '', decimalDigits: 2).format(transaction.amount)}', // Format amount with sign
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isExpense ? Colors.red.shade600 : Colors.green.shade600, // Color based on type
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DateFormat('MMM d, yyyy').format(transaction.date), // Format date
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), // Subtle color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_web_auth
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import sqlite3_flutter_libs
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterWebAuthPlugin.register(with: registry.registrar(forPlugin: "FlutterWebAuthPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
}
|
||||
|
||||
+79
-10
@@ -13,10 +13,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "13c1e6c6fd460522ea840abec3f677cc226f5fec7872c04ad7b425517ccf54f7"
|
||||
sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.4.4"
|
||||
version: "7.4.5"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -197,18 +197,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
|
||||
sha256: b584ddeb2b74436735dd2cf746d2d021e19a9a6770f409212fd5cbc2814ada85
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.26.0"
|
||||
version: "2.26.1"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588"
|
||||
sha256: "54dc207c6e4662741f60e5752678df183957ab907754ffab0372a7082f6d2816"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.26.0"
|
||||
version: "2.26.1"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -275,6 +275,19 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_web_auth
|
||||
sha256: "95e4856e24fb6ac1678f5ff334743b63f782d839ab324543d29ccbd295176209"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -303,10 +316,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
|
||||
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.4.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -539,6 +552,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.10"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -700,10 +769,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: bfe6f435f6ec49cb6c01da1e275ae4228719e59a6b067048c51e72d9d63bcc4b
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -32,6 +32,8 @@ dependencies:
|
||||
sdk: flutter
|
||||
fl_chart: ^0.71.0
|
||||
intl: ^0.19.0 # For date formatting
|
||||
flutter_web_auth: ^0.6.0 # Обновляем до последней стабильной версии
|
||||
shared_preferences: ^2.0.6 # Для работы с локальным хранилищем
|
||||
drift: ^2.18.0 # Updated Drift - основной пакет
|
||||
sqlite3_flutter_libs: ^0.5.24 # Needed for native platforms
|
||||
path_provider: ^2.1.3 # To find database file location on native
|
||||
|
||||
Reference in New Issue
Block a user