This commit is contained in:
Sanders committed 2025-05-12 19:08:22 +03:00
commit 295625c70f
151 files changed
+10677

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'screens/expenses_screen.dart';
import 'theme/app_theme.dart';
import 'database/database.dart'; // Import the database
class MyApp extends StatefulWidget {
final AppDatabase database; // Принимаем экземпляр базы данных
const MyApp({Key? key, required this.database}) : super(key: key);
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
// Состояние для управления темой (светлая/темная)
bool _isDarkMode = false;
// Метод для переключения темы
void toggleTheme() {
setState(() {
_isDarkMode = !_isDarkMode;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Finance App', // Обновленный заголовок
debugShowCheckedModeBanner: false, // Скрыть баннер Debug
themeMode: _isDarkMode ? ThemeMode.dark : ThemeMode.light, // Управление темой
theme: AppTheme.lightTheme, // Светлая тема
darkTheme: AppTheme.darkTheme, // Темная тема
home: ExpensesScreen(
database: widget.database, // Передаем базу данных в главный экран
toggleTheme: toggleTheme, // Передаем функцию переключения темы
isDarkMode: _isDarkMode, // Передаем текущее состояние темы
),
);
}
}
+432
View File
@@ -0,0 +1,432 @@
// 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<void>
},
);
// --- Методы для работы с транзакциями ---
// Получить все транзакции в виде потока, упорядоченные по дате (сначала новые)
// Возвращает non-nullable Stream<List<Transaction>> (Transaction - сгенерированный Drift класс)
Stream<List<Transaction>> watchAllTransactions() {
return (select(transactions)
..orderBy([(t) => OrderingTerm(expression: t.date, mode: OrderingMode.desc)]))
.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 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<int> addTransaction(TransactionsCompanion entry) {
// Убедимся, что тип указан
assert(entry.type.present && (entry.type.value == 'income' || entry.type.value == 'expense'));
// Убедимся, что для дохода используется специальная категория
if (entry.type.value == 'income') {
assert(entry.categoryName.present && entry.categoryName.value == 'Income');
}
return into(transactions).insert(entry);
}
// Обновить существующую транзакцию
// Принимает TransactionsCompanion, который должен содержать ID
// Возвращает true, если обновление прошло успешно
Future<bool> updateTransaction(TransactionsCompanion entry) {
// Проверяем, что ID предоставлен для обновления
assert(entry.id.present);
// Убедимся, что тип указан
assert(entry.type.present && (entry.type.value == 'income' || entry.type.value == 'expense'));
// Убедимся, что для дохода используется специальная категория
if (entry.type.value == 'income') {
assert(entry.categoryName.present && entry.categoryName.value == 'Income');
}
// Используем .replace() для обновления записи по ID
// replace возвращает true, если запись была обновлена (найдена по ID)
return update(transactions).replace(entry);
}
// Удалить транзакцию по ID
// Возвращает количество удаленных строк (0 или 1)
Future<int> deleteTransaction(int id) {
// Используем .delete() с условием where
return (delete(transactions)..where((t) => t.id.equals(id))).go();
}
// --- Методы для работы с категориями ---
// Получить все категории в виде потока, упорядоченные по имени
// Возвращает Stream<List<CategoryDb>> (CategoryDb - сгенерированный Drift класс)
Stream<List<CategoryDb>> watchAllCategoriesDb() {
return (select(categories)..orderBy([(c) => OrderingTerm(expression: c.name)])).watch();
}
// Добавить новую категорию
// Принимает CategoriesCompanion (сгенерированный Drift)
// Возвращает ID вставленной категории
Future<int> addCategory(CategoriesCompanion entry) {
// Проверяем, что имя, иконка и цвет предоставлены
assert(entry.name.present && entry.name.value.isNotEmpty);
assert(entry.icon.present); // Icon can be empty string if needed
assert(entry.color.present);
// Не позволяем добавить категорию с именем 'Income' (case-insensitive)
assert(entry.name.value.toLowerCase() != 'income');
return into(categories).insert(entry);
}
// Обновить существующую категорию
// Принимает CategoriesCompanion, который должен содержать ID
// Возвращает true, если обновление прошло успешно
Future<bool> updateCategory(CategoriesCompanion entry) {
// Проверяем, что ID предоставлен для обновления
assert(entry.id.present);
// Проверяем, что имя не 'Income' (case-insensitive)
if (entry.name.present && entry.name.value.toLowerCase() == 'income') {
print("Error: Cannot rename category to 'Income'.");
return Future.value(false); // Запрещаем переименование в 'Income'
}
// Используем транзакцию для проверки и обновления
return transaction(() async {
// Находим категорию по ID перед обновлением
final existingCategory = await (select(categories)..where((c) => c.id.equals(entry.id.value))).getSingleOrNull();
// Проверяем, существует ли категория и не является ли она 'Income'
if (existingCategory == null) {
print("Error: Category with ID ${entry.id.value} not found for update.");
return false; // Категория не найдена
}
if (existingCategory.name == 'Income') {
print("Error: Cannot update the 'Income' category.");
return false; // Запрещаем обновление категории 'Income'
}
// Выполняем обновление
// Метод replace возвращает true, если запись была обновлена
final updated = await update(categories).replace(entry);
// Если имя категории было изменено, нужно обновить categoryName во всех связанных транзакциях
if (updated && entry.name.present && entry.name.value != existingCategory.name) {
print("Category name changed from '${existingCategory.name}' to '${entry.name.value}'. Updating transactions...");
final updatedTransactions = await (update(transactions)
..where((t) => t.categoryName.equals(existingCategory.name)))
.write(TransactionsCompanion(
categoryName: Value(entry.name.value),
));
print("Updated $updatedTransactions transactions with the new category name.");
}
return updated; // Возвращаем результат replace
});
}
// Удалить категорию по ID
// Возвращает количество удаленных строк (0 или 1)
Future<int> deleteCategory(int id) {
// Используем транзакцию для проверок и удаления
return transaction(() async {
// Находим категорию по ID перед удалением
final categoryToDelete = await (select(categories)..where((c) => c.id.equals(id))).getSingleOrNull();
// Проверяем, существует ли категория
if (categoryToDelete == null) {
print("Error: Category with ID $id not found for deletion.");
return 0; // Категория не найдена
}
// Запрещаем удаление категории 'Income'
if (categoryToDelete.name == 'Income') {
print("Error: Cannot delete the 'Income' category.");
return 0; // Возвращаем 0, т.к. ничего не удалено
}
// Проверяем, есть ли транзакции с этой категорией
// Используем имя категории для связи (т.к. нет внешнего ключа)
final query = selectOnly(transactions)
..addColumns([transactions.id.count()])
..where(transactions.categoryName.equals(categoryToDelete.name)); // Используем имя для связи
final result = await query.getSingleOrNull();
final transactionCount = result?.read(transactions.id.count()) ?? 0;
if (transactionCount > 0) {
print("Error: Cannot delete category '${categoryToDelete.name}' because it has $transactionCount associated transaction(s).");
// Можно выбросить исключение или вернуть 0, чтобы показать неудачу
// throw Exception("Cannot delete category with transactions.");
return 0; // Не удаляем, возвращаем 0
}
// Если транзакций нет, удаляем категорию
print("Deleting category '${categoryToDelete.name}' (ID: $id)...");
final deletedRows = await (delete(categories)..where((c) => c.id.equals(id))).go();
print("Deleted $deletedRows category row(s).");
return deletedRows; // Возвращаем количество удаленных строк
});
}
// Получить категорию по ID (если нужно)
Future<CategoryDb?> getCategoryById(int id) {
return (select(categories)..where((c) => c.id.equals(id))).getSingleOrNull();
}
// --- Методы для агрегации и отчетов ---
// Вычислить и наблюдать за общими суммами по категориям РАСХОДОВ
// Возвращает Stream<List<Category>> где Category - это класс модели из '../models/category.dart'
Stream<List<Category>> calculateCategoryTotals() {
// 1. Создаем поток, который объединяет транзакции и категории
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) {
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 {
// Проверяем, есть ли уже категории (на всякий случай, хотя в 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.");
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
// lib/database/database_connection/connection.dart
import 'package:drift/drift.dart';
// Этот файл служит базой для условного импорта.
// Реализации находятся в connection_web.dart и connection_native.dart.
// Определим функцию-заглушку, чтобы основной файл database.dart
// мог ее импортировать без ошибок анализатора.
// Во время выполнения будет вызвана реализация из соответствующего
// файла (web или native) благодаря условному импорту.
QueryExecutor connect() => throw UnsupportedError(
'Stub connect function should not be called. Ensure conditional imports are set up correctly.');
@@ -0,0 +1,33 @@
// lib/database/database_connection/connection_native.dart
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
// sqlite3_flutter_libs и sqlite3 импортируются для возможной тонкой настройки,
// но часто NativeDatabase справляется автоматически.
// import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
// import 'package:sqlite3/sqlite3.dart';
QueryExecutor connect() {
print("Connecting to database using NativeDatabase (Lazy)");
// Используем LazyDatabase для отложенной инициализации на нативных платформах
return LazyDatabase(() async {
// Получаем папку для хранения документов приложения
final dbFolder = await getApplicationDocumentsDirectory();
// Создаем путь к файлу 'db.sqlite' в этой папке
final file = File(p.join(dbFolder.path, 'db.sqlite'));
print("Database file path (Native): ${file.path}"); // Логируем путь для отладки
// Настройка библиотеки sqlite3 (обычно не требуется для Android/iOS/macOS с sqlite3_flutter_libs)
// if (Platform.isWindows || Platform.isLinux) {
// // Может потребоваться дополнительная настройка для Desktop
// // await applyWorkaroundToOpenSqlite3Library();
// }
// Используем NativeDatabase для открытия соединения
// logStatements: true полезен для отладки SQL-запросов
return NativeDatabase(file, logStatements: false);
});
}
@@ -0,0 +1,15 @@
// lib/database/database_connection/connection_web.dart
import 'package:drift/drift.dart';
import 'package:drift/web.dart';
// import 'package:drift/wasm.dart'; // <-- Удалить этот импорт, он больше не нужен здесь
QueryExecutor connect() {
print("Connecting to database using WebDatabase (default configuration)");
// Используем стандартный конструктор WebDatabase.
// Drift попытается автоматически найти и загрузить sqlite3.wasm по пути /sqlite3.wasm
return WebDatabase(
'db', // Имя базы данных в IndexedDB
logStatements: false,
);
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; // Для kIsWeb
// import 'package:drift/wasm.dart'; // <-- Удалить этот импорт
import 'app.dart'; // Import the new app root widget
import 'database/database.dart'; // Import the database
Future<void> main() async {
// Необходимо для асинхронных операций перед runApp, например, инициализации БД
WidgetsFlutterBinding.ensureInitialized();
// --- Удалить весь этот блок ---
// // Инициализация WASM-модуля SQLite для веб-платформы
// if (kIsWeb) {
// print("Running on Web, attempting to initialize sqlite3.wasm...");
// // Указываем Drift, где найти файл sqlite3.wasm.
// // WasmDatabase.resolveFile попытается найти его автоматически (обычно в корне /sqlite3.wasm).
// final result = await WasmDatabase.resolveFile('sqlite3.wasm'); // <-- Ошибка здесь
//
// if (result.isSuccessful) {
// print("sqlite3.wasm loaded successfully.");
// } else {
// // Если файл не найден или произошла ошибка загрузки
// print("Error loading sqlite3.wasm: ${result.errorMessage}");
// // Здесь можно предпринять действия, если загрузка не удалась,
// // например, показать сообщение об ошибке пользователю или использовать
// // альтернативное хранилище. Пока просто выводим ошибку в консоль.
// // Приложение может не работать корректно без базы данных.
// }
// } else {
// print("Running on Native platform, skipping WASM initialization.");
// }
// --- Конец удаляемого блока ---
// Создаем единственный экземпляр базы данных для всего приложения
// WebDatabase (вызываемый через connect() на вебе) должен сам справиться с WASM
final database = AppDatabase();
// Опционально: Вставляем начальные данные, если база данных пуста
// Это полезно для первого запуска или демонстрации
// Делаем это после создания экземпляра БД
await database.insertInitialDataIfNeeded();
// Запускаем приложение, передавая экземпляр базы данных
runApp(MyApp(database: database));
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class Category {
final String name;
final double amount;
final Color colorCode;
final IconData iconCode;
Category(this.name, this.amount, this.colorCode, this.iconCode);
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'category.dart';
class TransactionRecord {
final int id;
final String type; // 'income' or 'expense'
final double amount;
final Category? category; // Reference to category (null for income)
final DateTime date;
final String merchant;
TransactionRecord({
required this.id,
required this.type,
required this.amount,
this.category,
required this.date,
required this.merchant,
});
// Removed the hardcoded id getter
// get id => 1;
}
File diff suppressed because it is too large. Load diff
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Hero(
tag: 'profileAvatar', // Tag must match the one in ExpensesScreen
child: Material( // Wrap with Material for Hero animation
type: MaterialType.transparency,
child: CircleAvatar(
radius: 50,
backgroundColor: isDark ? Colors.green.shade800 : Colors.green.shade100,
child: const Icon(
Icons.person,
size: 50,
color: Colors.green,
),
),
),
),
const SizedBox(height: 24),
Text(
'John Doe',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
),
),
const SizedBox(height: 8),
Text(
'john.doe@example.com',
style: TextStyle(
fontSize: 16,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {},
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('Edit Profile'),
),
],
),
),
);
}
}
+49
View File
@@ -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(...),
],
),
);
}
}
+211
View File
@@ -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),
),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
brightness: Brightness.light,
primaryColor: Colors.green,
scaffoldBackgroundColor: Colors.grey.shade50,
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.light,
seedColor: Colors.green,
primary: Colors.green,
secondary: Colors.green.shade300,
surface: Colors.white,
background: Colors.grey.shade50,
),
cardTheme: CardTheme(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
color: Colors.white,
),
appBarTheme: AppBarTheme(
backgroundColor: Colors.white,
elevation: 0,
iconTheme: IconThemeData(color: Colors.green.shade700),
titleTextStyle: const TextStyle(
color: Colors.black87,
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: 'Montserrat', // Ensure font family is applied here too
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: Colors.white,
selectedItemColor: Colors.green,
unselectedItemColor: Colors.grey.shade600,
),
fontFamily: 'Montserrat',
);
}
static ThemeData get darkTheme {
return ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.green.shade400,
scaffoldBackgroundColor: const Color(0xFF121212),
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: Colors.green,
primary: Colors.green.shade400,
secondary: Colors.green.shade200,
surface: const Color(0xFF222222), // Used for Card background in dark theme
background: const Color(0xFF121212),
onSurface: Colors.white, // Default text color on surface
),
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
color: const Color(0xFF1E1E1E), // Darker card color
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1A1A1A), // Slightly different dark background
elevation: 0,
iconTheme: IconThemeData(color: Colors.greenAccent),
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: 'Montserrat', // Ensure font family is applied here too
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: Colors.green.shade400,
foregroundColor: Colors.black87, // Better contrast on light green
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: const Color(0xFF1A1A1A),
selectedItemColor: Colors.green.shade300,
unselectedItemColor: Colors.grey.shade500,
),
textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.white), // Default text style
bodyMedium: TextStyle(color: Colors.white70), // Secondary text style
// Define other text styles if needed
).apply( // Ensure font family is applied globally for text
fontFamily: 'Montserrat',
),
fontFamily: 'Montserrat',
);
}
}
+247
View File
@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import '../models/category.dart'; // Import the Category model for return types
// Helper class to manage category details (icon, color) based on name
class CategoryUtils {
// Private map storing details for each predefined category name.
// Using a record `({IconData icon, Color color})` for concise structure.
// 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 (e.g., deleted category).
// DEPRECATED: Use getIconFromString and default color instead.
// static final _defaultDetails = (iconCode: Icons.category_outlined, colorCode: Colors.grey.shade500);
/// 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) {
// 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);
}
/// 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) {
final details = getCategoryDetails(name);
return Category(name, amount, details.colorCode, details.iconCode);
}
/// 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 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,*/
];
}
+268
View File
@@ -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('Сохранить'),
),
],
);
}
}
+299
View File
@@ -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)}";
}
}
+382
View File
@@ -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
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('Сохранить'),
),
],
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
class ExpandableSection extends StatelessWidget {
final String title;
final IconData icon;
final bool isExpanded;
final VoidCallback onTap;
final Widget child;
final Animation<double> heightFactor;
const ExpandableSection({
Key? key,
required this.title,
required this.icon,
required this.isExpanded,
required this.onTap,
required this.heightFactor,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
decoration: BoxDecoration(
color: isDark ? Colors.grey.shade800 : Colors.green.shade100,
borderRadius: BorderRadius.vertical(
top: const Radius.circular(16),
bottom: Radius.circular(isExpanded ? 0 : 16),
),
),
child: Row(
children: [
Icon(
icon,
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
),
),
const Spacer(),
AnimatedRotation(
turns: isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 300),
child: Icon(
Icons.keyboard_arrow_down,
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
),
),
],
),
),
),
AnimatedBuilder(
animation: heightFactor,
builder: (context, innerChild) {
return ClipRect(
child: Align(
heightFactor: heightFactor.value,
child: Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(16),
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withOpacity(0.3)
: Colors.green.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: innerChild,
),
),
);
},
child: child,
),
],
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class FilterChipWidget extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const FilterChipWidget({
Key? key,
required this.label,
required this.isSelected,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), // Smaller padding
decoration: BoxDecoration(
color: isSelected
? (isDark ? Colors.green.shade700 : Colors.green.shade100)
: (isDark ? Colors.grey.shade800 : Colors.grey.shade200),
borderRadius: BorderRadius.circular(14), // Smaller radius
border: isSelected
? Border.all(
color: isDark ? Colors.green.shade300 : Colors.green,
width: 1,
)
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 12, // Smaller text
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected
? (isDark ? Colors.white : Colors.green.shade700)
: (isDark ? Colors.white70 : Colors.grey.shade700),
),
),
),
);
}
}
+318
View File
@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart'; // For number formatting
import '../models/category.dart'; // Keep using the Category model for UI structure
// 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 Animation<double> animation; // For fade/scale animation
const SpendingPieChart({
Key? key,
required this.categories,
required this.totalExpenses,
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;
final theme = Theme.of(context);
final currencyFormatter = NumberFormat.currency(locale: 'en_US', symbol: '\$'); // Or your preferred locale/symbol
// Handle the case where there are no categories to display
if (widget.categories.isEmpty) {
return AnimatedBuilder( // Still use animation for consistency
animation: widget.animation,
builder: (context, child) => Opacity(
opacity: widget.animation.value,
child: Container(
height: 230, // Maintain similar height to the chart version
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: Text(
'No spending data for this period.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
),
),
);
}
// Use AnimatedBuilder to apply the fade/scale animation
return AnimatedBuilder(
animation: widget.animation,
builder: (context, child) {
return Transform.scale(
scale: widget.animation.value, // Apply scale animation
child: Opacity(
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
child: Row(
children: [
// --- Pie Chart ---
Expanded(
flex: 5, // Give more space to the chart itself
child: Stack( // Use Stack to overlay text on the chart center
alignment: Alignment.center,
children: [
PieChart(
PieChartData(
// Handle touch events on the pie chart
pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event, PieTouchResponse? pieTouchResponse) {
// We are only interested in TapUp events to trigger selection changes
if (event is FlTapUpEvent) {
final section = pieTouchResponse?.touchedSection;
if (section != null) {
// Tap occurred ON a section
final touchedIndex = section.touchedSectionIndex;
// Use internal handler to update state
_handlePieTap(touchedIndex);
} else {
// Tap occurred OUTSIDE any section
// Deselect if something was selected
if (_selectedPieIndex != -1) {
_handlePieTap(-1); // Pass -1 to deselect
}
}
}
},
),
borderData: FlBorderData(show: false), // No border around the chart
sectionsSpace: 2, // Space between slices
centerSpaceRadius: 50, // Radius of the center hole
sections: _generatePieSections(context), // Generate slices data
startDegreeOffset: -90, // Start chart from the top (12 o'clock)
),
// Optional animation when data changes
swapAnimationDuration: const Duration(milliseconds: 250),
swapAnimationCurve: Curves.easeInOut,
),
// --- Center Text (Displayed when a slice is selected) ---
if (_selectedPieIndex != -1)
_buildCenterText(context, currencyFormatter)
else // Optional: Display total or default text when nothing is selected
_buildDefaultCenterText(context, currencyFormatter),
],
),
),
const SizedBox(width: 8), // Spacing between chart and legend
// --- Legend ---
Expanded(
flex: 4, // Allocate space for the legend
// Use ListView for scrollable legend if many categories
child: ListView.builder(
itemCount: widget.categories.length,
padding: const EdgeInsets.only(right: 8), // Padding for legend items
itemBuilder: (context, index) => _buildPieLegendItem(context, index),
),
),
],
),
),
),
);
},
);
}
// Builds the text displayed in the center when a slice is selected
Widget _buildCenterText(BuildContext context, NumberFormat formatter) {
final theme = Theme.of(context);
// Check if selectedPieIndex is valid before accessing categories
if (_selectedPieIndex < 0 || _selectedPieIndex >= widget.categories.length) {
// Return an empty container or default text if index is invalid
return _buildDefaultCenterText(context, formatter);
}
final selectedCategory = widget.categories[_selectedPieIndex];
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
selectedCategory.name,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.textTheme.bodyLarge?.color?.withOpacity(0.8),
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
formatter.format(selectedCategory.amount), // Format the amount as currency
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: selectedCategory.colorCode, // Use category color for amount
),
textAlign: TextAlign.center,
),
],
);
}
// Builds the default text displayed in the center when no slice is selected
Widget _buildDefaultCenterText(BuildContext context, NumberFormat formatter) {
final theme = Theme.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Total', // Label for the total amount
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.textTheme.bodyLarge?.color?.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
formatter.format(widget.totalExpenses), // Display total expenses
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.textTheme.bodyLarge?.color // Use default text color
),
textAlign: TextAlign.center,
),
],
);
}
// Builds a single item for the legend
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 using internal state
// Check if index is valid before accessing categories
if (index < 0 || index >= widget.categories.length) {
return const SizedBox.shrink(); // Return empty if index is invalid
}
final category = widget.categories[index];
// Calculate percentage, handle totalExpenses being zero
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: () => _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
margin: const EdgeInsets.symmetric(vertical: 3.0), // Spacing between legend items
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), // Padding inside item
decoration: BoxDecoration(
// Highlight background if selected
color: isSelected
? category.colorCode.withOpacity(isDark ? 0.3 : 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
// Add border if selected
border: Border.all(
color: isSelected ? category.colorCode.withOpacity(0.8) : Colors.transparent,
width: 1.5,
),
),
child: Row(
children: [
// Color indicator dot
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: category.colorCode.withOpacity(isDark ? 0.9 : 1.0), // Use category color
),
),
const SizedBox(width: 8), // Spacing
// Category name
Expanded(
child: Text(
category.name,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
color: theme.textTheme.bodyLarge?.color, // Use default body text color
),
overflow: TextOverflow.ellipsis, // Prevent long names from wrapping
),
),
const SizedBox(width: 8), // Spacing
// Percentage text
Text(
'${percentage.toStringAsFixed(1)}%', // Format percentage
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), // Slightly faded color
),
),
],
),
),
);
}
// Generates the data for each slice (PieChartSectionData) of the pie chart
List<PieChartSectionData> _generatePieSections(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return List.generate(widget.categories.length, (i) {
// Check if index is valid before accessing categories
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 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 = widget.categories[i];
// Calculate percentage for the title
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
value: category.amount, // Value determines the slice size
title: '${percentage.toStringAsFixed(0)}%', // Display percentage as title
radius: radius, // Apply radius (larger if touched)
titleStyle: TextStyle(
fontSize: titleFontSize, // Apply font size (larger if touched)
fontWeight: FontWeight.bold,
color: Colors.white.withOpacity(0.9), // White text for contrast on colored slices
shadows: const [Shadow(color: Colors.black38, blurRadius: 2)], // Subtle shadow for readability
),
// Add border to selected slice for emphasis
borderSide: isTouched
? BorderSide(color: isDark ? Colors.white60 : Colors.black54, width: 2)
: BorderSide(color: category.colorCode.withOpacity(0.5), width: 1),
// Optional: Add badge (icon) to the selected slice
// badgeWidget: isTouched ? Icon(category.icon, color: Colors.white, size: 16) : null,
// badgePositionPercentageOffset: .98,
);
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
class SummaryItem extends StatelessWidget {
final IconData icon;
final String title;
final String amount;
final Color color;
const SummaryItem({
Key? key,
required this.icon,
required this.title,
required this.amount,
required this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
Row(
children: [
Icon(
icon,
size: 16,
color: isDark ? color.withOpacity(0.8) : color,
),
const SizedBox(width: 4),
Text(
title,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
),
),
],
),
const SizedBox(height: 4),
Text(
amount,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
],
);
}
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
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 model class
final VoidCallback? onEdit; // Callback for edit action
final VoidCallback? onDelete; // Callback for delete action
const TransactionListItem({
Key? key,
required this.transaction,
this.onEdit, // Make callbacks optional
this.onDelete,
}) : super(key: key);
// Function to show the options menu
void _showOptionsMenu(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) {
return SafeArea( // Use SafeArea to avoid system UI
child: Column(
mainAxisSize: MainAxisSize.min, // Take minimum space
children: <Widget>[
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Редактировать'),
onTap: () {
Navigator.pop(context); // Close the bottom sheet
onEdit?.call(); // Call the edit callback if it exists
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text('Удалить', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context); // Close the bottom sheet
onDelete?.call(); // Call the delete callback if it exists
},
),
// Optional: Add a Cancel button
ListTile(
leading: const Icon(Icons.cancel_outlined),
title: const Text('Отмена'),
onTap: () => Navigator.pop(context),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
final 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
),
),
],
),
],
),
),
);
}
}