Rework add trans

This commit is contained in:
2025-07-17 14:53:25 +03:00
parent 77701f6b35
commit b8278828a6
2 changed files with 484 additions and 118 deletions
+118
View File
@@ -0,0 +1,118 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Build and Run
- `flutter run` - Run the app in debug mode
- `flutter build apk` - Build APK for Android
- `flutter build ios` - Build for iOS
### Code Generation
- `flutter packages pub run build_runner build` - Generate Hive adapters and other generated code
- `flutter packages pub run build_runner build --delete-conflicting-outputs` - Clean build with conflict resolution
### Testing and Quality
- `flutter test` - Run all tests
- `flutter analyze` - Run static analysis using flutter_lints
- `flutter pub get` - Install dependencies
- `flutter pub upgrade` - Upgrade dependencies
### Localization
- `flutter gen-l10n` - Generate localization files (configured in l10n.yaml)
## Architecture Overview
This is a Flutter budget tracking application with SMS transaction parsing capabilities. The app follows Clean Architecture principles with BLoC pattern for state management.
### Key Architectural Components
**Dependency Injection**: Uses GetIt for dependency injection with a two-stage initialization:
- Global dependencies (user management, auth) initialized at startup
- User-specific dependencies (transactions, categories, etc.) initialized after authentication
**Database**: Hive (local NoSQL database) for data persistence with code generation for type adapters
**State Management**: BLoC pattern with flutter_bloc:
- Blocs for complex state (AuthBloc, TransactionBloc)
- Cubits for simpler state (UserCubit, CategoryCubit, TagCubit, SettingsCubit, SmsCubit)
**SMS Integration**: Uses another_telephony package to read SMS messages and automatically parse bank transaction notifications
### Project Structure
```
lib/
├── data/
│ ├── database/ # Hive database service
│ └── repositories/ # Data access layer with interfaces
├── hive/ # Hive type adapters (generated)
├── logic/ # BLoC/Cubit state management
├── models/ # Data models with Hive adapters
├── pages/ # UI screens and widgets
├── services/ # Business logic services
├── theme/ # App theming
├── utils/ # Utility functions
├── l10n/ # Localization files
├── main.dart # App entry point
└── injection_container.dart # Dependency injection setup
```
### Key Models
- `User` - User authentication and profile
- `TransactionRecord` - Financial transactions
- `Category` - Transaction categories with icons/colors
- `Tag` - Transaction tags
- `SmsMessage` - SMS messages for transaction parsing
- `AppSettings` - User preferences (theme, language)
### Authentication Flow
1. App starts with AuthBloc checking existing user
2. If no user, shows LoginPage
3. After login, calls `initUserSpecificDependencies()` to set up user data
4. User data is scoped and isolated per user via Hive boxes
### SMS Transaction Processing
The app can automatically parse SMS messages from banks to create transactions:
- Monitors incoming SMS via another_telephony
- Parses transaction details using configurable patterns
- Creates transactions automatically with suggested categories
### Localization
- Supports Russian (ru) and English (en)
- Uses flutter_localizations with ARB files
- Configured in l10n.yaml
## Coding Style Guidelines
### Naming Conventions
- Interface names should be prefixed with `I` (e.g., `IUserService`, `ITransactionRepository`)
- Private class members should be prefixed with an underscore (`_`)
- Follow dart naming conventions: camelCase for variables/methods, PascalCase for classes
### Flutter-Specific Best Practices
- **Prefer composition and small components**: Break down UI into small, reusable components rather than writing large monolithic widgets
- **Design for good user experience**: Provide clear, minimal, and non-blocking UI states
- **Use lightweight placeholders**: When data is loading, show skeleton screens rather than heavy loading indicators
- **Optimize for Flutter Compiler**: Write code that enables automatic optimizations and reduces unnecessary re-renders
### BLoC/Cubit Architecture
- Use **Blocs** for complex state management with events (AuthBloc, TransactionBloc)
- Use **Cubits** for simpler state management (UserCubit, CategoryCubit, TagCubit, SettingsCubit, SmsCubit)
- Follow the established pattern in the `logic/` directory
- Ensure proper separation of concerns between UI and business logic
### Performance Considerations
- Create small, focused widgets that can be efficiently rebuilt
- Use `const` constructors where possible
- Implement proper `shouldRebuild` logic in BlocBuilder/BlocListener
- Avoid heavy operations in build methods
## Important Notes
- The project uses Russian comments and some Russian text in UI
- SMS functionality requires Android permissions for reading SMS
- Hive boxes are user-scoped for data isolation
- The default test is outdated and needs updating for the actual app structure
- Build runner is required for Hive adapter generation after model changes
+366 -118
View File
@@ -21,6 +21,15 @@ class AddTransactionDialog extends StatefulWidget {
@override
State<AddTransactionDialog> createState() => _AddTransactionDialogState();
static Future<void> show(BuildContext context, {TransactionRecord? transaction}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddTransactionDialog(transaction: transaction),
);
}
}
class _AddTransactionDialogState extends State<AddTransactionDialog> {
@@ -31,14 +40,26 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
bool _isIncome = false;
Category? _selectedCategory;
// Комментарий: Добавляем состояние для выбранного тега.
Tag? _selectedTag;
// Комментарий: Заменяем _selectedDate на _selectedDateTime для хранения даты и времени.
DateTime _selectedDateTime = DateTime.now();
List<Category> _expenseCategories = [];
List<Category> _incomeCategories = [];
List<Tag> _tags = [];
bool _isTagsLoaded = false;
@override
void initState() {
super.initState();
// Кэшируем категории один раз
final allCategories = CategoryUtils.getDefaultCategories();
_expenseCategories = allCategories.where((c) => !c.isIncome).toList();
_incomeCategories = allCategories.where((c) => c.isIncome).toList();
// Загружаем теги один раз
_loadTags();
// Если передан transaction - заполняем поля его данными
if (widget.transaction != null) {
final t = widget.transaction!;
@@ -51,6 +72,20 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
}
_dateController.text = DateFormat('dd-MM-yyyy').add_Hm().format(_selectedDateTime);
}
Future<void> _loadTags() async {
try {
final tags = await GetIt.instance<ITagRepository>().getAll();
setState(() {
_tags = tags;
_isTagsLoaded = true;
});
} catch (e) {
setState(() {
_isTagsLoaded = true;
});
}
}
@override
void dispose() {
@@ -60,6 +95,102 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
super.dispose();
}
Widget _buildIncomeExpenseToggle(BuildContext context, AppLocalizations localizations, ThemeData theme) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
if (_isIncome) {
setState(() {
_isIncome = false;
_selectedCategory = null;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: !_isIncome ? theme.colorScheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.remove_circle_outline,
color: !_isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withOpacity(0.7),
size: 18,
),
const SizedBox(width: 8),
Text(
localizations.expense,
style: TextStyle(
color: !_isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withOpacity(0.7),
fontWeight: !_isIncome ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () {
if (!_isIncome) {
setState(() {
_isIncome = true;
_selectedCategory = null;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _isIncome ? theme.colorScheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_circle_outline,
color: _isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withOpacity(0.7),
size: 18,
),
const SizedBox(width: 8),
Text(
localizations.income,
style: TextStyle(
color: _isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withOpacity(0.7),
fontWeight: _isIncome ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
),
),
],
),
);
}
// Комментарий: Этот метод теперь обрабатывает выбор и даты, и времени.
Future<void> _selectDateTime(BuildContext context) async {
final DateTime? pickedDate = await showDatePicker(
@@ -168,130 +299,247 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final categories = CategoryUtils.getDefaultCategories()
.where((c) => c.isIncome == _isIncome).toList();
return AlertDialog(
title: Text(localizations.addTransactionButton),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SwitchListTile(
title: Text(localizations.income),
value: _isIncome,
onChanged: (bool value) {
setState(() {
_isIncome = value;
_selectedCategory =
null; // Сбрасываем категорию при смене типа
});
},
final categories = _isIncome ? _incomeCategories : _expenseCategories;
return Material(
child: Container(
height: mediaQuery.size.height * 0.9,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Text(
widget.transaction != null
? 'Редактировать транзакцию'
: localizations.addTransactionButton,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
TextFormField(
controller: _amountController,
decoration: InputDecoration(labelText: localizations.amount),
// Комментарий: Устанавливаем числовую клавиатуру с поддержкой десятичных чисел.
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
// Комментарий: Добавляем фильтр для ввода только чисел и одной точки.
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
),
),
_buildIncomeExpenseToggle(context, localizations, theme),
Expanded(
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
TextFormField(
controller: _amountController,
decoration: InputDecoration(
labelText: localizations.amount,
prefixIcon: const Icon(Icons.attach_money),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return localizations.requiredField;
}
if (double.tryParse(value) == null) {
return localizations.invalidNumber;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _vendorController,
decoration: InputDecoration(
labelText: localizations.vendor,
prefixIcon: const Icon(Icons.store),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
validator: (value) {
if (value == null || value.isEmpty) {
return localizations.requiredField;
}
return null;
},
),
const SizedBox(height: 16),
_buildCategoryField(localizations, theme, categories),
const SizedBox(height: 16),
_buildTagField(localizations, theme),
const SizedBox(height: 16),
_buildDateField(localizations, theme),
],
),
),
),
),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(top: BorderSide(color: theme.dividerColor)),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(localizations.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(localizations.save),
),
),
],
validator: (value) {
if (value == null || value.isEmpty) {
return localizations.requiredField;
}
if (double.tryParse(value) == null) {
return localizations.invalidNumber;
}
return null;
},
),
TextFormField(
controller: _vendorController,
decoration: InputDecoration(labelText: localizations.vendor),
validator: (value) {
if (value == null || value.isEmpty) {
return localizations.requiredField;
}
return null;
},
),
DropdownButtonFormField<Category>(
value: _selectedCategory,
decoration: InputDecoration(labelText: localizations.category),
items: categories.map((Category category) {
return DropdownMenuItem<Category>(
value: category,
child: Text(category.name),
);
}).toList(),
onChanged: (Category? newValue) {
setState(() {
_selectedCategory = newValue;
});
},
validator: (value) =>
value == null ? localizations.requiredField : null,
),
// Комментарий: Добавляем выпадающий список для выбора тега.
// Он будет загружать теги асинхронно для текущего пользователя.
FutureBuilder<List<Tag>>(
future: GetIt.instance<ITagRepository>().getAll(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
final tags = snapshot.data ?? [];
return DropdownButtonFormField<Tag>(
value: _selectedTag,
decoration: InputDecoration(labelText: localizations.tag),
items: tags.map((Tag tag) {
return DropdownMenuItem<Tag>(
value: tag,
child: Text(tag.name),
);
}).toList(),
onChanged: (Tag? newValue) {
setState(() {
_selectedTag = newValue;
});
},
// Комментарий: Тег не является обязательным полем.
);
},
),
TextFormField(
controller: _dateController,
decoration: InputDecoration(
labelText: localizations.date,
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today),
// Комментарий: Вызываем новый метод для выбора даты и времени.
onPressed: () => _selectDateTime(context),
),
),
],
),
),
);
}
Widget _buildCategoryField(AppLocalizations localizations, ThemeData theme, List<Category> categories) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: DropdownButtonFormField<Category>(
key: ValueKey(_isIncome),
value: _selectedCategory,
decoration: InputDecoration(
labelText: localizations.category,
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
items: categories.map((Category category) {
return DropdownMenuItem<Category>(
value: category,
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: category.color,
shape: BoxShape.circle,
),
child: Icon(
category.icon,
color: Colors.white,
size: 16,
),
),
readOnly: true,
),
],
),
),
const SizedBox(width: 12),
Text(category.name),
],
),
);
}).toList(),
onChanged: (Category? newValue) {
setState(() {
_selectedCategory = newValue;
});
},
validator: (value) =>
value == null ? localizations.requiredField : null,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(localizations.cancel),
);
}
Widget _buildTagField(AppLocalizations localizations, ThemeData theme) {
if (!_isTagsLoaded) {
return const SizedBox(
height: 56,
child: Center(child: CircularProgressIndicator()),
);
}
return DropdownButtonFormField<Tag>(
value: _selectedTag,
decoration: InputDecoration(
labelText: localizations.tag,
prefixIcon: const Icon(Icons.label),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
ElevatedButton(onPressed: _submitForm, child: Text(localizations.save)),
],
filled: true,
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
items: _tags.map((Tag tag) {
return DropdownMenuItem<Tag>(
value: tag,
child: Text(tag.name),
);
}).toList(),
onChanged: (Tag? newValue) {
setState(() {
_selectedTag = newValue;
});
},
);
}
Widget _buildDateField(AppLocalizations localizations, ThemeData theme) {
return TextFormField(
controller: _dateController,
decoration: InputDecoration(
labelText: localizations.date,
prefixIcon: const Icon(Icons.schedule),
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: () => _selectDateTime(context),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: theme.colorScheme.surfaceVariant.withOpacity(0.3),
),
readOnly: true,
);
}
}