/opt/canhelp/apps/mobile/lib/screens/tasks
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!,
],
),
),
),
);
}
}