Rework add trans
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user