/opt/canhelp/apps/mobile/lib/screens/tasks
NameSizeModeActions
create_task_screen.dart618530644editdlrm
edit_task_screen.dart343350644editdlrm
make_offer_screen.dart158420644editdlrm
tasks_list_screen.dart446580644editdlrm
task_detail_screen.dart638310644editdlrm
Edit: /opt/canhelp/apps/mobile/lib/screens/tasks/create_task_screen.dart (61853B)
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import '../../models/category.dart'; import '../../models/location.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/locale_provider.dart'; import '../../services/api_client.dart'; import '../../theme/app_theme.dart'; // ── Design tokens ───────────────────────────────────────────────────────────── class CreateTaskScreen extends StatefulWidget { final String? categorySlug; const CreateTaskScreen({super.key, this.categorySlug}); @override State createState() => _CreateTaskScreenState(); } class _CreateTaskScreenState extends State { final _formKey = GlobalKey(); final _titleController = TextEditingController(); final _descriptionController = TextEditingController(); final _budgetController = TextEditingController(); final _districtController = TextEditingController(); final _streetController = TextEditingController(); final _houseController = TextEditingController(); final _confidentialController = TextEditingController(); String? _selectedCategory; String? _selectedLocation; DateTime? _deadlineDate; int _expiresAtDays = 30; String? _selectedTimeSlot; bool _budgetNegotiable = false; String? _error; bool _submitting = false; int _currentStep = 0; // 0-3 static const _totalSteps = 4; final List _uploadedImages = []; bool _uploadingPhoto = false; // used in _pickPhoto InputDecoration _inputDec(String hint) => InputDecoration( hintText: hint, hintStyle: const TextStyle(color: AppTheme.textHint, fontSize: 14), filled: true, fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppTheme.inputRadius), borderSide: const BorderSide(color: AppTheme.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppTheme.inputRadius), borderSide: const BorderSide(color: AppTheme.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppTheme.inputRadius), borderSide: const BorderSide(color: AppTheme.primary, width: 2), ), ); List _categories = []; List _locationTree = []; static const _fallbackCategories = [ 'cleaning', 'plumbing', 'electrical', 'moving', 'tutoring', 'beauty', 'it_support', 'repairs', 'other', ]; @override void initState() { super.initState(); if (widget.categorySlug != null) { _selectedCategory = widget.categorySlug; } // Initialize publication date to today _deadlineDate = DateTime.now(); WidgetsBinding.instance.addPostFrameCallback((_) => _loadData()); } Future _loadData() async { final api = ApiClient(); await Future.wait([ api .getCategories() .then((cats) { if (mounted && cats.isNotEmpty) setState(() => _categories = cats); }) .catchError((_) {}), api .getLocations() .then((locs) { if (mounted && locs.isNotEmpty) { setState(() => _locationTree = locs); } }) .catchError((_) {}), ]); } @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); _budgetController.dispose(); _districtController.dispose(); _streetController.dispose(); _houseController.dispose(); _confidentialController.dispose(); super.dispose(); } Future _pickPhoto() async { final picker = ImagePicker(); final picked = await picker.pickImage( source: ImageSource.gallery, imageQuality: 85, maxWidth: 1200, ); if (picked == null || !mounted) return; setState(() => _uploadingPhoto = true); try { final bytes = await picked.readAsBytes(); final url = await ApiClient().uploadFile(bytes, picked.name); if (mounted) setState(() => _uploadedImages.add(url)); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '${context.read().t('createTask.uploadError')}: $e', ), ), ); } } finally { if (mounted) setState(() => _uploadingPhoto = false); } } Future _submit(String currentLocale) async { if (!_formKey.currentState!.validate() || _submitting) return; setState(() { _error = null; _submitting = true; }); final budgetText = _budgetController.text.trim(); final budget = budgetText.isEmpty ? null : double.tryParse(budgetText); try { final task = await context.read().createTask( title: _titleController.text.trim(), description: _descriptionController.text.trim(), budget: budget, budgetNegotiable: _budgetNegotiable, category: _selectedCategory, location: _selectedLocation, locale: currentLocale, deadline: _deadlineDate, expiresAt: DateTime.now().add(Duration(days: _expiresAtDays)), timeSlot: _selectedTimeSlot, district: _districtController.text.trim().isEmpty ? null : _districtController.text.trim(), street: _streetController.text.trim().isEmpty ? null : _streetController.text.trim(), houseNumber: _houseController.text.trim().isEmpty ? null : _houseController.text.trim(), images: _uploadedImages.isEmpty ? null : _uploadedImages, confidentialNote: _confidentialController.text.trim().isEmpty ? null : _confidentialController.text.trim(), ); if (!mounted) return; final msg = context.read().t('createTask.created'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(msg), backgroundColor: AppTheme.primary, behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 3), ), ); context.pushReplacement('/tasks/${task.id}'); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _submitting = false; }); } } } bool _validateStep() { switch (_currentStep) { case 0: // Details return _titleController.text.trim().length >= 5 && _descriptionController.text.trim().length >= 20; case 1: // Location — optional, always valid return true; case 2: // Budget — optional, always valid return true; default: return true; } } void _nextStep(String locale) { if (_currentStep < _totalSteps - 1) { setState(() { _error = null; _currentStep++; }); } else { _submit(locale); } } void _prevStep() { if (_currentStep > 0) { setState(() => _currentStep--); } else if (context.canPop()) context.pop(); else context.go('/'); } @override Widget build(BuildContext context) { final locale = context.watch(); final categoryItems = _categories.isNotEmpty ? _categories : _fallbackCategories .map( (slug) => Category( id: slug, slug: slug, icon: '', names: {'en': locale.t('category.$slug')}, ), ) .toList(); final stepLabels = [ locale.t('createTask.mainInfo'), locale.t('createTask.locationLabel'), locale.t('createTask.budgetSection'), locale.t('createTask.stepReview'), ]; return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( backgroundColor: AppTheme.background, elevation: 0, scrolledUnderElevation: 0, foregroundColor: AppTheme.textPrimary, leading: IconButton( icon: const Icon( Icons.arrow_back_ios_new_rounded, size: 20, color: AppTheme.textPrimary, ), onPressed: _prevStep, ), title: Column( children: [ Text( stepLabels[_currentStep], style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), Text( locale .t('specialist_setup.step_of') .replaceAll('{current}', '${_currentStep + 1}') .replaceAll('{total}', '$_totalSteps'), style: const TextStyle( fontSize: 11, color: AppTheme.textSecondary, ), ), ], ), centerTitle: true, bottom: PreferredSize( preferredSize: const Size.fromHeight(50), child: _WizardProgress( current: _currentStep, total: _totalSteps, labels: stepLabels, ), ), ), body: Form( key: _formKey, child: Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.only(top: 8, bottom: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── Error banner ── if (_error != null) Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4, ), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular( AppTheme.inputRadius, ), border: Border.all(color: const Color(0xFFFCA5A5)), ), child: Text( _error!, style: const TextStyle( fontSize: 13, color: AppTheme.danger, ), ), ), ), // ── Step 0: Details ────────────────────────────────── if (_currentStep == 0) ...[ _FormCard( title: locale.t('createTask.mainInfo'), children: [ _FieldLabel( locale.t('createTask.titleLabel'), required: true, ), const SizedBox(height: 6), TextFormField( controller: _titleController, style: const TextStyle( fontSize: 15, color: AppTheme.textPrimary, ), decoration: _inputDec( locale.t('createTask.titleHint'), ), onChanged: (_) => setState(() {}), validator: (v) => v == null || v.trim().length < 5 ? locale.t('createTask.titleMinLength') : null, ), const SizedBox(height: 16), _FieldLabel(locale.t('tasks.category')), const SizedBox(height: 6), _DropdownPicker( value: _selectedCategory, placeholder: locale.t('createTask.selectCategory'), items: [ _PickerItem( label: locale.t('createTask.notSelected'), value: null, isParent: false, ), ...categoryItems.map( (cat) => _PickerItem( label: cat.nameFor(locale.locale), value: cat.slug, icon: cat.uiIcon.isNotEmpty ? cat.uiIcon : null, isParent: cat.children.isNotEmpty, children: cat.children .map( (sub) => _PickerItem( label: sub.nameFor(locale.locale), value: sub.slug, isParent: false, ), ) .toList(), ), ), ], onChanged: (v) => setState(() => _selectedCategory = v), ), const SizedBox(height: 16), _FieldLabel( locale.t('createTask.descriptionLabel'), required: true, ), const SizedBox(height: 6), TextFormField( controller: _descriptionController, maxLines: 5, style: const TextStyle( fontSize: 15, color: AppTheme.textPrimary, ), decoration: _inputDec( locale.t('createTask.descriptionHint'), ), onChanged: (_) => setState(() {}), validator: (v) => v == null || v.trim().length < 20 ? locale.t('createTask.descriptionMinLength') : null, ), ], ), ], // ── Step 1: Location ───────────────────────────────── if (_currentStep == 1) ...[ _FormCard( title: locale.t('createTask.categoryAndLocation'), children: [ _FieldLabel(locale.t('createTask.locationLabel')), const SizedBox(height: 6), _DropdownPicker( value: _selectedLocation, placeholder: locale.t('createTask.anyLocation'), items: [ _PickerItem( label: locale.t('createTask.anyLocation'), value: null, isParent: false, ), // Remote location first (if exists) ...(_locationTree .where( (city) => city.slug.toLowerCase() == 'remote' || city.nameEn.toLowerCase() == 'remote', ) .map( (city) => _PickerItem( label: city.nameFor(locale.locale), value: city.slug, isParent: city.children.isNotEmpty, children: city.children .map( (d) => _PickerItem( label: d.nameFor( locale.locale, ), value: d.slug, isParent: false, ), ) .toList(), ), )) .toList(), // Other cities ..._locationTree .where( (city) => city.slug.toLowerCase() != 'remote' && city.nameEn.toLowerCase() != 'remote', ) .map( (city) => _PickerItem( label: city.nameFor(locale.locale), value: city.slug, isParent: true, children: [ _PickerItem( label: '${locale.t('createTask.entireCity')} — ${city.nameFor(locale.locale)}', value: city.slug, isParent: false, ), ...city.children.map( (d) => _PickerItem( label: d.nameFor(locale.locale), value: d.slug, isParent: false, ), ), ], ), ), ], onChanged: (v) => setState(() => _selectedLocation = v), ), const SizedBox(height: 16), _FieldLabel(locale.t('createTask.addressOptional')), const SizedBox(height: 6), Row( children: [ Expanded( child: TextFormField( controller: _districtController, style: const TextStyle( fontSize: 15, color: AppTheme.textPrimary, ), decoration: _inputDec( locale.t('createTask.district'), ), ), ), const SizedBox(width: 10), Expanded( child: TextFormField( controller: _streetController, style: const TextStyle( fontSize: 15, color: AppTheme.textPrimary, ), decoration: _inputDec( locale.t('createTask.street'), ), ), ), ], ), const SizedBox(height: 10), TextFormField( controller: _houseController, style: const TextStyle( fontSize: 15, color: AppTheme.textPrimary, ), decoration: _inputDec(locale.t('createTask.house')), ), ], ), ], // ── Step 2: Budget + Timeline + Photos ─────────────── if (_currentStep == 2) ...[ _FormCard( title: locale.t('createTask.budgetSection'), children: [ _FieldLabel(locale.t('createTask.budgetAmount')), const SizedBox(height: 6), TextFormField( controller: _budgetController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), style: const TextStyle( fontSize: 15, color: AppTheme.textPrimary, ), decoration: _inputDec( locale.t('createTask.budgetExample'), ), ), const SizedBox(height: 12), GestureDetector( onTap: () => setState( () => _budgetNegotiable = !_budgetNegotiable, ), child: Row( children: [ AnimatedContainer( duration: const Duration(milliseconds: 150), width: 22, height: 22, decoration: BoxDecoration( color: _budgetNegotiable ? AppTheme.primary : Colors.white, borderRadius: BorderRadius.circular(6), border: Border.all( color: _budgetNegotiable ? AppTheme.primary : AppTheme.border, width: 1.5, ), ), child: _budgetNegotiable ? const Icon( Icons.check_rounded, size: 14, color: Colors.white, ) : null, ), const SizedBox(width: 10), Text( locale.t('createTask.priceNegotiable'), style: const TextStyle( fontSize: 14, color: AppTheme.textPrimary, ), ), ], ), ), ], ), // Photos section (from existing build) _FormCard( title: locale.t('createTask.deadlineSection'), children: [ _DatePickerRow( value: _deadlineDate, onChanged: (d) => setState(() => _deadlineDate = d), ), const SizedBox(height: 16), _FieldLabel(locale.t('createTask.expiresAtLabel')), const SizedBox(height: 8), Row( children: [7, 14, 30, 60].map((days) { final isSelected = _expiresAtDays == days; return Expanded( child: Padding( padding: const EdgeInsets.only(right: 6), child: GestureDetector( onTap: () => setState(() => _expiresAtDays = days), child: AnimatedContainer( duration: const Duration( milliseconds: 150, ), padding: const EdgeInsets.symmetric( vertical: 9, ), decoration: BoxDecoration( color: isSelected ? AppTheme.primary : Colors.white, borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), border: Border.all( color: isSelected ? AppTheme.primary : AppTheme.border, width: isSelected ? 2 : 1, ), ), child: Text( '$days\n${locale.t('plan.days')}', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : AppTheme.textSecondary, height: 1.3, ), ), ), ), ), ); }).toList(), ), const SizedBox(height: 12), _FieldLabel(locale.t('createTask.timeLabel')), const SizedBox(height: 8), _TimeSlotPicker( value: _selectedTimeSlot, onChanged: (s) => setState(() => _selectedTimeSlot = s), ), ], ), _FormCard( title: locale.t('createTask.photosSection'), children: [ if (_uploadedImages.isNotEmpty) ...[ SizedBox( height: 88, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: _uploadedImages.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (_, i) => ClipRRect( borderRadius: BorderRadius.circular( AppTheme.smallRadius, ), child: Image.network( _uploadedImages[i], width: 88, height: 88, fit: BoxFit.cover, ), ), ), ), const SizedBox(height: 12), ], OutlinedButton.icon( onPressed: _uploadingPhoto ? null : _pickPhoto, icon: _uploadingPhoto ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon( Icons.add_photo_alternate_outlined, ), label: Text(locale.t('createTask.addPhoto')), style: OutlinedButton.styleFrom( foregroundColor: AppTheme.primary, side: const BorderSide(color: AppTheme.primary), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), ), ], ), ], // ── Step 3: Review ─────────────────────────────────── if (_currentStep == 3) _ReviewCard( titleCtrl: _titleController, descCtrl: _descriptionController, budgetCtrl: _budgetController, budgetNegotiable: _budgetNegotiable, selectedCategory: _selectedCategory, selectedLocation: _selectedLocation, categoryItems: categoryItems, locationTree: _locationTree, locale: locale, ), ], ), ), ), // ── Bottom navigation ──────────────────────────────────────── Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), decoration: const BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: AppTheme.border)), ), child: SafeArea( top: false, child: Row( children: [ if (_currentStep > 0) OutlinedButton( onPressed: _prevStep, style: OutlinedButton.styleFrom( foregroundColor: AppTheme.textSecondary, side: const BorderSide(color: AppTheme.border), padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 14, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), child: Text(locale.t('specialist_setup.back')), ), if (_currentStep > 0) const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: (_submitting || (_currentStep == 0 && !_validateStep())) ? null : () => _nextStep(locale.locale), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primary, foregroundColor: Colors.white, disabledBackgroundColor: AppTheme.border, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), child: _submitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Text( _currentStep < _totalSteps - 1 ? locale.t('specialist_setup.next') : locale.t('createTask.submitTask'), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), ), ), ], ), ), ), ], ), ), ); } } // ─── Wizard progress indicator ──────────────────────────────────────────── class _WizardProgress extends StatelessWidget { final int current; final int total; final List labels; const _WizardProgress({ required this.current, required this.total, required this.labels, }); @override Widget build(BuildContext context) { const dotActive = 12.0; const dotSmall = 8.0; const lineH = 2.0; const topPadLine = (dotActive - lineH) / 2; // 5.0 return Padding( padding: const EdgeInsets.fromLTRB(12, 6, 12, 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: List.generate(total * 2 - 1, (i) { if (i.isOdd) { final stepIdx = i ~/ 2; final done = stepIdx < current; return Expanded( child: Padding( padding: const EdgeInsets.only(top: topPadLine), child: AnimatedContainer( duration: const Duration(milliseconds: 300), height: lineH, color: done ? AppTheme.primary : AppTheme.border, ), ), ); } final stepIdx = i ~/ 2; final isActive = stepIdx == current; final isDone = stepIdx < current; final size = isActive ? dotActive : dotSmall; return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: EdgeInsets.only(top: (dotActive - size) / 2), child: AnimatedContainer( duration: const Duration(milliseconds: 300), width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, color: (isActive || isDone) ? AppTheme.primary : Colors.transparent, border: Border.all( color: (isActive || isDone) ? AppTheme.primary : AppTheme.border, width: 1.5, ), ), child: isDone ? const Icon( Icons.check_rounded, color: Colors.white, size: 5, ) : null, ), ), const SizedBox(height: 4), Text( stepIdx < labels.length ? labels[stepIdx] : '', style: TextStyle( fontSize: 11, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, color: isActive ? AppTheme.primary : AppTheme.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ); }), ), ); } } // ─── Review card (step 3 summary) ───────────────────────────────────────── class _ReviewCard extends StatelessWidget { final TextEditingController titleCtrl; final TextEditingController descCtrl; final TextEditingController budgetCtrl; final bool budgetNegotiable; final String? selectedCategory; final String? selectedLocation; final List categoryItems; final List locationTree; final LocaleProvider locale; const _ReviewCard({ required this.titleCtrl, required this.descCtrl, required this.budgetCtrl, required this.budgetNegotiable, required this.selectedCategory, required this.selectedLocation, required this.categoryItems, required this.locationTree, required this.locale, }); @override Widget build(BuildContext context) { String catName = ''; if (selectedCategory != null) { for (final c in categoryItems) { if (c.slug == selectedCategory) { catName = c.nameFor(locale.locale); break; } for (final sub in c.children) { if (sub.slug == selectedCategory) { catName = sub.nameFor(locale.locale); break; } } } } String locName = ''; if (selectedLocation != null) { for (final city in locationTree) { if (city.slug == selectedLocation) { locName = city.nameFor(locale.locale); break; } for (final d in city.children) { if (d.slug == selectedLocation) { locName = d.nameFor(locale.locale); break; } } } } return _FormCard( title: 'Review', children: [ _ReviewRow( label: locale.t('createTask.titleLabel'), value: titleCtrl.text, ), if (catName.isNotEmpty) _ReviewRow(label: locale.t('tasks.category'), value: catName), if (locName.isNotEmpty) _ReviewRow(label: locale.t('tasks.location'), value: locName), if (budgetCtrl.text.isNotEmpty) _ReviewRow( label: locale.t('tasks.budget'), value: '€${budgetCtrl.text}', ), if (budgetNegotiable && budgetCtrl.text.isEmpty) _ReviewRow( label: locale.t('tasks.budget'), value: locale.t('tasks.budgetNegotiable'), ), const SizedBox(height: 8), Text( descCtrl.text, style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, height: 1.5, ), ), ], ); } } class _ReviewRow extends StatelessWidget { final String label; final String value; const _ReviewRow({required this.label, required this.value}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: Text( label, style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), ), ), Expanded( child: Text( value, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), ), ), ], ), ); } } class _FormCard extends StatelessWidget { final String title; final List children; const _FormCard({required this.title, required this.children}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.cardRadius), boxShadow: const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 8, offset: Offset(0, 2), ), ], ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.textSecondary, letterSpacing: 0.4, ), ), const SizedBox(height: 12), ...children, ], ), ); } } class _FieldLabel extends StatelessWidget { final String text; final bool required; const _FieldLabel(this.text, {this.required = false}); @override Widget build(BuildContext context) { return Row( children: [ Text( text, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppTheme.textPrimary, ), ), if (required) ...[ const SizedBox(width: 4), const Text( '*', style: TextStyle(color: Color(0xFFEF4444), fontSize: 13), ), ], ], ); } } // ── Date picker row ──────────────────────────────────────────────────────────── class _DatePickerRow extends StatelessWidget { final DateTime? value; final ValueChanged onChanged; const _DatePickerRow({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final now = DateTime.now(); // Show dates starting from today for up to 30 days final dates = List.generate(30, (i) => now.add(Duration(days: i))); return SizedBox( height: 72, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: dates.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, i) { final d = dates[i]; final isSelected = value != null && d.year == value!.year && d.month == value!.month && d.day == value!.day; final day = d.day.toString(); final month = _shortMonth(d.month, context.read()); return GestureDetector( onTap: () => onChanged(isSelected ? null : d), child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 52, decoration: BoxDecoration( color: isSelected ? AppTheme.primary : Colors.white, borderRadius: BorderRadius.circular(AppTheme.inputRadius), border: Border.all( color: isSelected ? AppTheme.primary : AppTheme.border, width: isSelected ? 2 : 1, ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( month, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, color: isSelected ? Colors.white70 : AppTheme.textSecondary, ), ), Text( day, style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, color: isSelected ? Colors.white : AppTheme.textPrimary, height: 1.1, ), ), ], ), ), ); }, ), ); } String _shortMonth(int m, LocaleProvider locale) { const keys = [ 'createTask.monthJan', 'createTask.monthFeb', 'createTask.monthMar', 'createTask.monthApr', 'createTask.monthMay', 'createTask.monthJun', 'createTask.monthJul', 'createTask.monthAug', 'createTask.monthSep', 'createTask.monthOct', 'createTask.monthNov', 'createTask.monthDec', ]; return locale.t(keys[m - 1]); } } // ── Time slot picker ─────────────────────────────────────────────────────────── class _TimeSlotPicker extends StatelessWidget { final String? value; final ValueChanged onChanged; const _TimeSlotPicker({required this.value, required this.onChanged}); static const _slotKeys = [ ('', 'createTask.slotAny'), ('morning', 'createTask.slotMorning'), ('afternoon', 'createTask.slotAfternoon'), ('evening', 'createTask.slotEvening'), ('weekend', 'createTask.slotWeekend'), ]; @override Widget build(BuildContext context) { final locale = context.watch(); return Wrap( spacing: 8, runSpacing: 8, children: _slotKeys.map((slot) { final isSelected = (value ?? '') == slot.$1; return GestureDetector( onTap: () => onChanged(slot.$1.isEmpty ? null : slot.$1), child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? AppTheme.primaryLight : Colors.white, borderRadius: BorderRadius.circular(AppTheme.chipRadius), border: Border.all( color: isSelected ? AppTheme.primary : AppTheme.border, width: isSelected ? 1.5 : 1, ), ), child: Text( locale.t(slot.$2), style: TextStyle( fontSize: 13, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? AppTheme.primary : AppTheme.textSecondary, ), ), ), ); }).toList(), ); } } // ── Dropdown picker (same as in tasks_list_screen) ───────────────────────────── class _PickerItem { final String label; final String? value; final bool isParent; final String? icon; final List<_PickerItem> children; const _PickerItem({ required this.label, required this.value, required this.isParent, this.icon, this.children = const [], }); } class _DropdownPicker extends StatefulWidget { final String? value; final String placeholder; final List<_PickerItem> items; final ValueChanged onChanged; const _DropdownPicker({ required this.value, required this.placeholder, required this.items, required this.onChanged, }); @override State<_DropdownPicker> createState() => _DropdownPickerState(); } class _DropdownPickerState extends State<_DropdownPicker> { bool _open = false; String? _expandedParent; late TextEditingController _searchController; String _searchQuery = ''; @override void initState() { super.initState(); _searchController = TextEditingController(); } @override void dispose() { _searchController.dispose(); super.dispose(); } bool _matchesSearch(String label) { if (_searchQuery.isEmpty) return true; return label.toLowerCase().contains(_searchQuery.toLowerCase()); } String _labelFor(String? val) { if (val == null) return widget.placeholder; for (final item in widget.items) { if (item.value == val) return item.label; for (final child in item.children) { if (child.value == val) return child.label; } } return widget.placeholder; } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ GestureDetector( onTap: () => setState(() { _open = !_open; if (!_open) _expandedParent = null; }), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: _open ? const BorderRadius.vertical(top: Radius.circular(12)) : BorderRadius.circular(AppTheme.inputRadius), border: Border.all( color: _open ? AppTheme.primary : AppTheme.border, width: _open ? 2 : 1, ), ), child: Row( children: [ Expanded( child: Text( _labelFor(widget.value), style: TextStyle( fontSize: 14, color: widget.value == null ? AppTheme.textHint : AppTheme.textPrimary, fontWeight: widget.value == null ? FontWeight.w400 : FontWeight.w500, ), ), ), Icon( _open ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, size: 20, color: AppTheme.textHint, ), ], ), ), ), if (_open) Container( constraints: const BoxConstraints(maxHeight: 300), decoration: BoxDecoration( color: Colors.white, borderRadius: const BorderRadius.vertical( bottom: Radius.circular(12), ), border: const Border( left: BorderSide(color: AppTheme.primary, width: 2), right: BorderSide(color: AppTheme.primary, width: 2), bottom: BorderSide(color: AppTheme.primary, width: 2), ), ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Divider(height: 1, color: AppTheme.border), Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: TextField( controller: _searchController, autofocus: false, onChanged: (v) => setState(() => _searchQuery = v), decoration: InputDecoration( hintText: context.read().t( 'common.search', 'Поиск...', ), hintStyle: const TextStyle( color: AppTheme.textHint, fontSize: 14, ), prefixIcon: const Icon( Icons.search, size: 18, color: AppTheme.textHint, ), suffixIcon: _searchQuery.isNotEmpty ? GestureDetector( onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); }, child: const Icon( Icons.clear, size: 18, color: AppTheme.textHint, ), ) : null, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppTheme.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppTheme.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide( color: AppTheme.primary, width: 2, ), ), ), style: const TextStyle( fontSize: 14, color: AppTheme.textPrimary, ), ), ), const Divider(height: 1, color: AppTheme.border), ...widget.items .where((item) { if (_searchQuery.isEmpty) return true; if (_matchesSearch(item.label)) return true; if (item.isParent && item.children.any((c) => _matchesSearch(c.label))) return true; return false; }) .map((item) { if (!item.isParent || item.children.isEmpty) { if (!_matchesSearch(item.label)) return const SizedBox.shrink(); return _PickerRow( label: item.label, icon: item.icon, selected: widget.value == item.value, indent: false, onTap: () { widget.onChanged(item.value); setState(() { _open = false; _expandedParent = null; _searchController.clear(); _searchQuery = ''; }); }, ); } final expanded = _expandedParent == item.value || (_searchQuery.isNotEmpty && item.children.any( (c) => _matchesSearch(c.label), )); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _PickerRow( label: item.label, icon: item.icon, selected: false, indent: false, trailing: Icon( expanded ? Icons.expand_less_rounded : Icons.chevron_right_rounded, size: 18, color: AppTheme.textHint, ), onTap: () => setState(() { _expandedParent = (_expandedParent == item.value) ? null : item.value; }), ), if (expanded) ...item.children .where((c) => _matchesSearch(c.label)) .map( (child) => _PickerRow( label: child.label, selected: widget.value == child.value, indent: true, onTap: () { widget.onChanged(child.value); setState(() { _open = false; _expandedParent = null; _searchController.clear(); _searchQuery = ''; }); }, ), ), ], ); }), ], ), ), ), ], ); } } class _PickerRow extends StatelessWidget { final String label; final String? icon; final bool selected; final bool indent; final Widget? trailing; final VoidCallback onTap; const _PickerRow({ required this.label, this.icon, required this.selected, required this.indent, this.trailing, required this.onTap, }); @override Widget build(BuildContext context) { return Material( color: Colors.white, child: InkWell( onTap: onTap, child: Padding( padding: EdgeInsets.only( left: indent ? 28 : 14, right: 14, top: 10, bottom: 10, ), child: Row( children: [ if (icon != null && icon!.isNotEmpty) ...[ Text(icon!, style: const TextStyle(fontSize: 16)), const SizedBox(width: 8), ], Expanded( child: Text( label, style: TextStyle( fontSize: 13, color: selected ? AppTheme.primary : const Color(0xFF1E293B), fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), if (selected) const Icon( Icons.check_rounded, size: 16, color: AppTheme.primary, ) else if (trailing != null) trailing!, ], ), ), ), ); } }