56 Commits
Author SHA1 Message Date
Sanders 4e27dbf2ee fix 2025-05-12 16:06:22 +03:00
Sanders b3fd06f37b chore: обновить зависимость flutter_web_auth до версии 0.6.0 2025-05-12 13:46:36 +03:00
Sanders 2f46e7a554 feat: добавить импорт TelegramAuth в ProfileScreen 2025-05-12 13:43:51 +03:00
Sanders d90a3aef16 feat: Добавить зависимости для аутентификации и локального хранилища 2025-05-12 13:42:33 +03:00
Sanders ed77c948d7 feat: Добавить сохранение данных пользователя после логина через Telegram 2025-05-12 13:39:00 +03:00
Sanders 2ac3f27d64 feat: добавить возможность логина через Telegram 2025-05-12 13:35:07 +03:00
Sanders beb7e62d91 feat: Добавить поддержку userId в транзакциях и диалогах редактирования 2025-05-12 13:24:45 +03:00
Sanders 5a682ea4c6 feat: добавить ссылку на пользователя для категорий и транзакций 2025-05-12 13:23:08 +03:00
Sanders b20e751437 feat: добавить сущность пользователь в базу данных 2025-05-12 13:20:40 +03:00
Sanders 7f6e608ff1 fix: Reduce minWidth of ToggleButtons in edit dialog 2025-05-09 00:19:39 +03:00
Sanders 3931c76623 feat: Add long press menu for transactions with edit/delete options 2025-05-09 00:16:42 +03:00
Sanders 84dec567e0 refactor: Assign specific colors to initial categories 2025-05-09 00:00:12 +03:00
Sanders 3545dfacd7 fix: Use category color from DB when editing 2025-05-08 23:56:53 +03:00
Sanders 184cc82469 feat: Use available colors for initial categories 2025-05-08 23:35:58 +03:00
Sanders 050153b5ec refactor: Use alias for Color and Colors 2025-05-08 23:35:56 +03:00
Sanders ddf3d45bf4 fix: Set default color to existing category color in edit dialog 2025-05-08 23:30:32 +03:00
Sanders cfec739c97 refactor: Fix super constructor call in MultiStreamBuilder 2025-05-08 23:28:01 +03:00
Sanders 0a6267ec3a feat: Add settings menu screen and rename category settings 2025-05-08 23:27:05 +03:00
Sanders d9c70c1bb6 feat: Navigate to Settings screen from bottom nav bar 2025-05-08 23:17:48 +03:00
Sanders 4fee4b7eda feat: add category update and delete methods 2025-05-05 14:47:42 +03:00
Sanders 28a7bd6ea7 feat: implement category settings screen 2025-05-05 14:43:44 +03:00
Sanders 024037a233 refactor: remove unused settop_component_outlined icon 2025-05-05 14:43:37 +03:00
Sanders a1f020b141 fix: make icon picker grid scrollable in dialog 2025-05-04 22:57:17 +03:00
Sanders 65e72dbe9c feat: add more icons and color selection for categories 2025-05-04 22:55:21 +03:00
Sanders 8ce8add138 feat: add icon picker to category dialog 2025-05-04 22:52:58 +03:00
Sanders 8c9196f67f feat: add getIconFromString utility and use it 2025-05-04 22:49:57 +03:00
Sanders c6d7018b4c feat: add category creation dialog and integration 2025-05-04 22:47:36 +03:00
Sanders a0f3c016fa fix: insert initial data exclusively in onCreate 2025-05-04 22:40:49 +03:00
Sanders f14cde0247 refactor: Rename Category table to Categories and fix references 2025-05-04 22:38:05 +03:00
Sanders c5da00c013 refactor: rename Categories table to Category 2025-05-04 22:37:57 +03:00
Sanders e90aa8c8cc Вот исправленный файл с правильными именами таблиц и полей. Основные изменения:
1. Переименовал класс `Categories` в `Category` для единообразия
2. Обновил имя таблицы с `categories` на `category` в запросах
3. Исправил имена полей для соответствия новым названиям
4. Обновил типы полей (например, `categoryName` на `categoryId`)

Теперь ошибка "no such table" не должна возникать, так как все имена таблиц и полей согласованы.

Хотите, чтобы я показал полный обновленный файл с исправлениями?
2025-05-04 22:32:59 +03:00
Sanders a4c6023483 fix 2025-05-04 22:16:43 +03:00
Sanders 119f86eecf child: Column(
children: [
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text(
                      transaction.title,
                      style: theme.textTheme.subtitle1,
                    ),
                    Text(
                      formattedAmount,
                      style: theme.textTheme.subtitle1,
                    ),
                  ],
                ),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text(
                      dateFormatter.format(transaction.date),
                      style: theme.textTheme.caption,
                    ),
                    Text(
                      transaction.categoryName,
                      style: theme.textTheme.caption,
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
diff --git a/finance_app/lib/widgets/transaction_list_item.dart b/finance_app/lib/widgets/transaction_list_item.dart
index 1a2b3c4..a49954e 100644
--- a/finance_app/lib/widgets/transaction_list_item.dart
+++ b/finance_app/lib/widgets/transaction_list_item.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:flutter/material.dart';
 import 'package:intl/intl.dart';
 import 'package:finance_app/models/transaction.dart';
 import 'package:finance_app/utils/category_utils.dart';
@@ -18,10 +18,10 @@ class TransactionListItem extends StatelessWidget {
     final theme = Theme.of(context);
     final bool isIncome = transaction.type == 'income';

-    // Get category details ONLY for expense
-    final categoryDetails = !isIncome
-        ? CategoryUtils.getCategoryDetails(transaction.categoryName)
-        : null; // No specific category details needed for income display
+    // Get category details from the transaction's category reference
+    final categoryDetails = !isIncome && transaction.category != null
+        ? (iconCode: transaction.category!.iconCode, colorCode: transaction.category!.colorCode)
+        : null;

     // Format dates
     final dateFormatter = DateFormat.MMMd(); // e.g., Sep 10
@@ -51,8 +51,8 @@ class TransactionListItem extends StatelessWidget {
         : (isDark ? Colors.redAccent.shade100 : Colors.red.shade700); // Red for expense

     // Leading icon configuration
-    IconData leadingIconData = isIncome ? Icons.arrow_downward_rounded : (categoryDetails?.iconCode ?? Icons.error_outline); // Default icon for expense if details missing
-    Color leadingIconColor = isIncome ? amountColor : (categoryDetails?.colorCode ?? Colors.grey);
+    IconData leadingIconData = isIncome ? Icons.arrow_downward_rounded : (transaction.category?.iconCode ?? Icons.error_outline);
+    Color leadingIconColor = isIncome ? amountColor : (transaction.category?.colorCode ?? Colors.grey);
     Color leadingBackgroundColor = leadingIconColor.withOpacity(isDark ? 0.25 : 0.18);

     return Card(
refactor: update transaction list item to use category reference
2025-05-04 22:16:00 +03:00
Sanders 330cc4c14a refactor: remove categoryName from TransactionRecord and use category reference 2025-05-04 22:11:43 +03:00
Sanders 1deea23783 >> refactor: replace icon/color fields with category reference in TransactionRecord 2025-05-04 22:07:21 +03:00
Sanders 5a8bd79387 feat: add categories table to database 2025-05-04 21:44:11 +03:00
Sanders 010604ac6f fix: correct merchant column name and add null safety 2025-05-04 20:06:18 +03:00
Sanders 4e74cad421 # Commit message
refactor: Update transaction type handling in database and UI
2025-05-04 20:06:15 +03:00
Sanders 66ae257773 refactor: improve MultiStreamBuilder and snapshot handling 2025-05-04 16:43:21 +03:00
Sanders f411106154 (no commit message provided) 2025-05-04 16:38:29 +03:00
Sanders 49238fbded fix: improve snapshot handling and type safety in MultiStreamBuilder 2025-05-04 16:36:32 +03:00
Sanders 59265f53c2 (no commit message provided) 2025-05-04 16:16:22 +03:00
Sanders 7f8ed7245d fix: Explicitly type lambda parameter in snapshots.any 2025-05-04 16:08:12 +03:00
Sanders 7e3a759282 fix: resolve errors in MultiStreamBuilder and income display 2025-05-04 16:04:30 +03:00
Sanders 305f263735 fix: use column default for transaction type 2025-05-04 15:55:35 +03:00
Sanders ca522cde97 feat: add income tracking 2025-05-04 15:52:36 +03:00
Sanders d64211acb3 feat: allow specifying time when adding transaction 2025-05-04 15:46:12 +03:00
Sanders 8ab4288cda fix: Stop transaction list flicker during rebuilds 2025-05-04 15:40:52 +03:00
Sanders 7764ec092e fix: Prevent transaction list flicker on state change 2025-05-04 15:38:15 +03:00
Sanders a4d97db720 fix: remove duplicate category icon in add transaction form 2025-05-04 15:29:58 +03:00
Sanders c5cf706f38 feat: implement add transaction form via modal sheet 2025-05-04 15:12:09 +03:00
Sanders b9bf455631 refactor: Make SpendingPieChart stateful to prevent list flicker 2025-05-04 15:05:29 +03:00
Sanders b9bd506ee6 style: make transaction list items compact 2025-05-04 15:01:45 +03:00
Sanders d84778a5a7 style: reduce vertical spacing between transaction list items 2025-05-04 15:00:43 +03:00
Sanders f4ac8e0e56 refactor: Use ListTile for TransactionListItem layout 2025-05-04 14:59:34 +03:00
Sanders 42046b4e87 fix 2025-05-04 14:58:40 +03:00
24 changed files with 4204 additions and 394 deletions
+12
View File
@@ -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" />
+82 -10
View File
@@ -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" />
+1 -1
View File
@@ -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}"
+1
View File
@@ -19,3 +19,4 @@ subprojects {
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+41
View File
@@ -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}');
}
}
}
+354 -45
View File
@@ -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');
}
// Вычислить и наблюдать за общими суммами по категориям
Stream<List<Category>> calculateCategoryTotals() {
// 1. Получаем поток всех транзакций (теперь non-nullable Stream<List<Transaction>>)
return watchAllTransactions().map((transactionList) {
// 2. Группируем транзакции по categoryName и суммируем amount
final categoryTotals = <String, double>{};
// transactionList теперь содержит объекты Transaction, сгенерированные Drift
for (var transaction in transactionList) {
categoryTotals.update(
transaction.categoryName,
(value) => value + transaction.amount,
ifAbsent: () => transaction.amount,
);
// Используем .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'
}
// 3. Преобразуем сгруппированные данные в список объектов Category
// Выполняем обновление
// Метод 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. Создаем поток, который объединяет транзакции и категории
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>{};
for (var transaction in transactionList) {
// Суммируем только расходы (исключая 'Income')
if (transaction.type == 'expense') {
categoryTotals.update(
transaction.categoryName,
(value) => value + transaction.amount,
ifAbsent: () => transaction.amount,
);
}
}
// 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
+2 -1
View File
@@ -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});
}
+16 -5
View File
@@ -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;
}
+33
View File
@@ -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
+17 -1
View File
@@ -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),
),
);
}
}
+227 -31
View File
@@ -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('Сохранить'),
),
],
);
}
}
+44 -30
View File
@@ -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
+129 -104
View File
@@ -1,121 +1,146 @@
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);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
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}');
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0), // Consistent padding
child: Row(
children: [
// Icon container with category color/icon
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: categoryDetails.colorCode.withOpacity(isDark ? 0.25 : 0.15), // Use category color with opacity
borderRadius: BorderRadius.circular(12), // Rounded corners
),
child: Icon(
categoryDetails.iconCode, // Use category icon
color: categoryDetails.colorCode, // Use category color for icon
size: 20, // Icon size
),
// 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
},
),
const SizedBox(width: 12), // Spacing
// Transaction details (Merchant/Category and Date/Time)
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
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
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
},
),
const SizedBox(width: 12), // Spacing before amount
// Transaction Amount
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
),
// 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 isExpense = transaction.type == 'expense';
final theme = Theme.of(context);
// 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(horizontal: 16.0, vertical: 12.0), // Adjusted padding
child: Row(
children: [
// Category Icon (only for expenses)
if (isExpense && transaction.category != null)
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: transaction.category!.colorCode.withOpacity(0.1), // Use category color
shape: BoxShape.circle,
),
child: Icon(
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: 16), // Space between icon and text
// Transaction Details (Category/Merchant, Date)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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, // Prevent overflow
),
const SizedBox(height: 4),
Text(
// 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
View File
@@ -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:
+2
View File
@@ -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