// 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' // Базовый импорт if (dart.library.html) 'database_connection/connection_web.dart' // Для Веб if (dart.library.io) 'database_connection/connection_native.dart'; // Для Нативных платформ (Android, iOS, Desktop) // Импортируем модель категории для возвращаемого типа и утилиты import '../models/category.dart'; import '../utils/category_utils.dart'; // Helper for category details // Эта строка указывает Drift сгенерировать файл database.g.dart part 'database.g.dart'; // 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 color => integer()(); // Store color value (e.g., Colors.green.value) } // Определение таблицы Transactions // Имя таблицы в 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')(); // Имя категории (или 'Income') RealColumn get amount => real()(); // Сумма транзакции DateTimeColumn get date => dateTime()(); // Дата транзакции TextColumn get merchant => text().named('merchant')(); // Название продавца/источника // ИСПРАВЛЕНО: Добавлено .withDefault() для значения по умолчанию TextColumn get type => text().named('type').withDefault(const Constant('expense'))(); // Тип транзакции ('income' или 'expense') } // Класс базы данных // Аннотация @DriftDatabase указывает Drift сгенерировать код для этой базы данных @DriftDatabase(tables: [Categories, Transactions]) // Updated table name here class AppDatabase extends _$AppDatabase { // Используем функцию connect() из условного импорта для создания соединения // Во время компиляции будет выбрана правильная реализация connect() // из connection_web.dart или connection_native.dart. AppDatabase() : super(connect()); // Версия схемы. Увеличивайте при изменении структуры таблиц. @override 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 }, ); // --- Методы для работы с транзакциями --- // Получить все транзакции в виде потока, упорядоченные по дате (сначала новые) // Возвращает non-nullable Stream> (Transaction - сгенерированный Drift класс) Stream> watchAllTransactions() { return (select(transactions) ..orderBy([(t) => OrderingTerm(expression: t.date, mode: OrderingMode.desc)])) .watch(); } // Получить транзакции, отфильтрованные по категории РАСХОДОВ, в виде потока // Если categoryName == 'All', возвращает ВСЕ транзакции (и доходы, и расходы) // Возвращает non-nullable Stream> (Transaction - сгенерированный Drift класс) Stream> watchFilteredTransactions(String categoryName) { if (categoryName == 'All') { // watchAllTransactions теперь возвращает non-nullable Stream return watchAllTransactions(); // Return all types if filter is 'All' } // Otherwise, filter by the provided category name AND ensure it's an expense return (select(transactions) ..where((t) => t.categoryName.equals(categoryName) & t.type.equals('expense')) ..orderBy([(t) => OrderingTerm(expression: t.date, mode: OrderingMode.desc)])) .watch(); } // Добавить новую транзакцию // Принимает TransactionsCompanion - сгенерированный Drift класс (должен включать type) Future 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 updateTransaction(TransactionsCompanion entry) { // Проверяем, что ID предоставлен для обновления assert(entry.id.present); // Убедимся, что тип указан assert(entry.type.present && (entry.type.value == 'income' || entry.type.value == 'expense')); // Убедимся, что для дохода используется специальная категория if (entry.type.value == 'income') { assert(entry.categoryName.present && entry.categoryName.value == 'Income'); } // Используем .replace() для обновления записи по ID // replace возвращает true, если запись была обновлена (найдена по ID) return update(transactions).replace(entry); } // Удалить транзакцию по ID // Возвращает количество удаленных строк (0 или 1) Future deleteTransaction(int id) { // Используем .delete() с условием where return (delete(transactions)..where((t) => t.id.equals(id))).go(); } // --- Методы для работы с категориями --- // Получить все категории в виде потока, упорядоченные по имени // Возвращает Stream> (CategoryDb - сгенерированный Drift класс) Stream> watchAllCategoriesDb() { return (select(categories)..orderBy([(c) => OrderingTerm(expression: c.name)])).watch(); } // Добавить новую категорию // Принимает CategoriesCompanion (сгенерированный Drift) // Возвращает ID вставленной категории Future 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 updateCategory(CategoriesCompanion entry) { // Проверяем, что ID предоставлен для обновления assert(entry.id.present); // Проверяем, что имя не 'Income' (case-insensitive) if (entry.name.present && entry.name.value.toLowerCase() == 'income') { print("Error: Cannot rename category to 'Income'."); return Future.value(false); // Запрещаем переименование в 'Income' } // Используем транзакцию для проверки и обновления return transaction(() async { // Находим категорию по ID перед обновлением final existingCategory = await (select(categories)..where((c) => c.id.equals(entry.id.value))).getSingleOrNull(); // Проверяем, существует ли категория и не является ли она 'Income' if (existingCategory == null) { print("Error: Category with ID ${entry.id.value} not found for update."); return false; // Категория не найдена } if (existingCategory.name == 'Income') { print("Error: Cannot update the 'Income' category."); return false; // Запрещаем обновление категории 'Income' } // Выполняем обновление // Метод replace возвращает true, если запись была обновлена final updated = await update(categories).replace(entry); // Если имя категории было изменено, нужно обновить categoryName во всех связанных транзакциях if (updated && entry.name.present && entry.name.value != existingCategory.name) { print("Category name changed from '${existingCategory.name}' to '${entry.name.value}'. Updating transactions..."); final updatedTransactions = await (update(transactions) ..where((t) => t.categoryName.equals(existingCategory.name))) .write(TransactionsCompanion( categoryName: Value(entry.name.value), )); print("Updated $updatedTransactions transactions with the new category name."); } return updated; // Возвращаем результат replace }); } // Удалить категорию по ID // Возвращает количество удаленных строк (0 или 1) Future 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 getCategoryById(int id) { return (select(categories)..where((c) => c.id.equals(id))).getSingleOrNull(); } // --- Методы для агрегации и отчетов --- // Вычислить и наблюдать за общими суммами по категориям РАСХОДОВ // Возвращает Stream> где Category - это класс модели из '../models/category.dart' Stream> calculateCategoryTotals() { // 1. Создаем поток, который объединяет транзакции и категории final transactionsStream = watchAllTransactions(); final categoriesStream = watchAllCategoriesDb(); // Используем StreamZip для объединения последних данных из обоих потоков return StreamZip([transactionsStream, categoriesStream]).map((data) { final transactionList = data[0] as List; final categoryList = data[1] as List; // Создаем Map для быстрого доступа к деталям категории по имени final categoryDetailsMap = { for (var cat in categoryList) cat.name: cat }; // 2. Фильтруем только расходы и группируем по categoryName, суммируя amount final categoryTotals = {}; 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) { 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 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 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 insertInitialDataIfNeeded() async { // Проверяем, есть ли уже категории (на всякий случай, хотя в 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'), TransactionsCompanion.insert(categoryName: 'Shopping', amount: 132.75, date: DateTime.now().subtract(const Duration(days: 3, hours: 11)), merchant: 'Apple Store'), 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'), 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 transactions inserted successfully."); } else { // Эта ветка не должна выполняться при вызове из onCreate print("Transactions table already contains data ($transactionCount transactions). Skipping initial transaction insertion."); } } }