|
| 1 | +import "dart:async"; |
| 2 | + |
| 3 | +import "package:flutter/material.dart"; |
| 4 | +import "package:flutter/services.dart"; |
| 5 | + |
| 6 | +import "../../../db/isar/main_db.dart"; |
| 7 | +import "../../../models/shopinbit/shopinbit_order_model.dart"; |
| 8 | +import "../../../themes/stack_colors.dart"; |
| 9 | +import "../../../utilities/text_styles.dart"; |
| 10 | +import "../../../utilities/util.dart"; |
| 11 | +import "../../../widgets/rounded_white_container.dart"; |
| 12 | +import "../shopinbit_car_fee_view.dart"; |
| 13 | +import "../shopinbit_tickets_view.dart"; |
| 14 | +import "shopinbit_country_picker.dart"; |
| 15 | +import "shopinbit_labeled_checkbox.dart"; |
| 16 | +import "shopinbit_privacy_checkbox.dart"; |
| 17 | +import "shopinbit_step4_dropdown.dart"; |
| 18 | +import "shopinbit_step4_header.dart"; |
| 19 | +import "shopinbit_step4_submit_button.dart"; |
| 20 | +import "shopinbit_step4_text_field.dart"; |
| 21 | + |
| 22 | +const List<String> _carConditions = ["NEW", "PREOWNED"]; |
| 23 | + |
| 24 | +const int _minCarBudget = 20000; |
| 25 | +const int _minCarFieldLength = 3; |
| 26 | + |
| 27 | +class ShopInBitCarResearchForm extends StatefulWidget { |
| 28 | + const ShopInBitCarResearchForm({super.key, required this.model}); |
| 29 | + |
| 30 | + final ShopInBitOrderModel model; |
| 31 | + |
| 32 | + @override |
| 33 | + State<ShopInBitCarResearchForm> createState() => |
| 34 | + _ShopInBitCarResearchFormState(); |
| 35 | +} |
| 36 | + |
| 37 | +class _ShopInBitCarResearchFormState extends State<ShopInBitCarResearchForm> { |
| 38 | + final TextEditingController _brandController = TextEditingController(); |
| 39 | + final FocusNode _brandFocusNode = FocusNode(); |
| 40 | + bool _brandTouched = false; |
| 41 | + |
| 42 | + final TextEditingController _modelController = TextEditingController(); |
| 43 | + final FocusNode _modelFocusNode = FocusNode(); |
| 44 | + bool _modelTouched = false; |
| 45 | + |
| 46 | + final TextEditingController _carDescriptionController = |
| 47 | + TextEditingController(); |
| 48 | + final FocusNode _carDescriptionFocusNode = FocusNode(); |
| 49 | + bool _carDescriptionTouched = false; |
| 50 | + |
| 51 | + final TextEditingController _carBudgetController = TextEditingController(); |
| 52 | + final FocusNode _carBudgetFocusNode = FocusNode(); |
| 53 | + bool _carBudgetTouched = false; |
| 54 | + |
| 55 | + String? _selectedCarCondition; |
| 56 | + bool _feeAcknowledged = false; |
| 57 | + String? _selectedCountryIso; |
| 58 | + bool _privacyAccepted = false; |
| 59 | + bool _submitting = false; |
| 60 | + |
| 61 | + @override |
| 62 | + void initState() { |
| 63 | + super.initState(); |
| 64 | + _wireTouchOnBlur(_brandFocusNode, () => _brandTouched = true); |
| 65 | + _wireTouchOnBlur(_modelFocusNode, () => _modelTouched = true); |
| 66 | + _wireTouchOnBlur( |
| 67 | + _carDescriptionFocusNode, |
| 68 | + () => _carDescriptionTouched = true, |
| 69 | + ); |
| 70 | + _wireTouchOnBlur(_carBudgetFocusNode, () => _carBudgetTouched = true); |
| 71 | + if (widget.model.deliveryCountry.isNotEmpty) { |
| 72 | + _selectedCountryIso = widget.model.deliveryCountry; |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) { |
| 77 | + node.addListener(() { |
| 78 | + if (!node.hasFocus) markTouched(); |
| 79 | + setState(() {}); |
| 80 | + }); |
| 81 | + } |
| 82 | + |
| 83 | + @override |
| 84 | + void dispose() { |
| 85 | + _brandController.dispose(); |
| 86 | + _brandFocusNode.dispose(); |
| 87 | + _modelController.dispose(); |
| 88 | + _modelFocusNode.dispose(); |
| 89 | + _carDescriptionController.dispose(); |
| 90 | + _carDescriptionFocusNode.dispose(); |
| 91 | + _carBudgetController.dispose(); |
| 92 | + _carBudgetFocusNode.dispose(); |
| 93 | + super.dispose(); |
| 94 | + } |
| 95 | + |
| 96 | + bool get _canContinue { |
| 97 | + final int? carBudgetValue = int.tryParse(_carBudgetController.text.trim()); |
| 98 | + return !_submitting && |
| 99 | + _privacyAccepted && |
| 100 | + _feeAcknowledged && |
| 101 | + _brandController.text.trim().length >= _minCarFieldLength && |
| 102 | + _modelController.text.trim().length >= _minCarFieldLength && |
| 103 | + _carDescriptionController.text.trim().length >= _minCarFieldLength && |
| 104 | + _selectedCarCondition != null && |
| 105 | + carBudgetValue != null && |
| 106 | + carBudgetValue >= _minCarBudget && |
| 107 | + _selectedCountryIso != null; |
| 108 | + } |
| 109 | + |
| 110 | + Future<void> _submit() async { |
| 111 | + setState(() => _submitting = true); |
| 112 | + try { |
| 113 | + final String countryIso = _selectedCountryIso!; |
| 114 | + |
| 115 | + widget.model |
| 116 | + ..requestDescription = |
| 117 | + "Brand: ${_brandController.text.trim()}\n" |
| 118 | + "Model: ${_modelController.text.trim()}\n" |
| 119 | + "Condition: $_selectedCarCondition\n" |
| 120 | + "Description: ${_carDescriptionController.text.trim()}\n" |
| 121 | + "Budget: ${_carBudgetController.text.trim()} EUR\n" |
| 122 | + "Delivery country: $countryIso" |
| 123 | + ..deliveryCountry = countryIso; |
| 124 | + |
| 125 | + // Block if another car research flow is already in progress. |
| 126 | + final existingPending = MainDB.instance |
| 127 | + .getShopInBitTickets() |
| 128 | + .where((t) => t.isPendingPayment) |
| 129 | + .toList(); |
| 130 | + |
| 131 | + if (existingPending.isNotEmpty && mounted) { |
| 132 | + final bool? resumePrevious = await showDialog<bool>( |
| 133 | + context: context, |
| 134 | + barrierDismissible: false, |
| 135 | + builder: (ctx) => AlertDialog( |
| 136 | + title: const Text("In-Progress Car Research"), |
| 137 | + content: const Text( |
| 138 | + "You have an unfinished car research payment. " |
| 139 | + "Would you like to resume it or start a new search?", |
| 140 | + ), |
| 141 | + actions: [ |
| 142 | + TextButton( |
| 143 | + onPressed: () => Navigator.of(ctx).pop(true), |
| 144 | + child: const Text("Resume Previous"), |
| 145 | + ), |
| 146 | + TextButton( |
| 147 | + onPressed: () => Navigator.of(ctx).pop(false), |
| 148 | + child: const Text("Start New"), |
| 149 | + ), |
| 150 | + ], |
| 151 | + ), |
| 152 | + ); |
| 153 | + |
| 154 | + if (resumePrevious == true && mounted) { |
| 155 | + unawaited( |
| 156 | + Navigator.of(context).pushNamedAndRemoveUntil( |
| 157 | + ShopInBitTicketsView.routeName, |
| 158 | + (route) => route.isFirst, |
| 159 | + ), |
| 160 | + ); |
| 161 | + return; |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + if (!mounted) return; |
| 166 | + |
| 167 | + if (Util.isDesktop) { |
| 168 | + Navigator.of(context, rootNavigator: true).pop(); |
| 169 | + unawaited( |
| 170 | + showDialog<void>( |
| 171 | + context: context, |
| 172 | + builder: (_) => ShopInBitCarFeeView(model: widget.model), |
| 173 | + ), |
| 174 | + ); |
| 175 | + } else { |
| 176 | + unawaited( |
| 177 | + Navigator.of( |
| 178 | + context, |
| 179 | + ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model), |
| 180 | + ); |
| 181 | + } |
| 182 | + } finally { |
| 183 | + if (mounted) setState(() => _submitting = false); |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + @override |
| 188 | + Widget build(BuildContext context) { |
| 189 | + final bool isDesktop = Util.isDesktop; |
| 190 | + |
| 191 | + final String? brandError = |
| 192 | + _brandTouched && |
| 193 | + _brandController.text.trim().length < _minCarFieldLength |
| 194 | + ? "Minimum $_minCarFieldLength characters" |
| 195 | + : null; |
| 196 | + |
| 197 | + final String? modelError = |
| 198 | + _modelTouched && |
| 199 | + _modelController.text.trim().length < _minCarFieldLength |
| 200 | + ? "Minimum $_minCarFieldLength characters" |
| 201 | + : null; |
| 202 | + |
| 203 | + final String? carDescriptionError = |
| 204 | + _carDescriptionTouched && |
| 205 | + _carDescriptionController.text.trim().length < _minCarFieldLength |
| 206 | + ? "Minimum $_minCarFieldLength characters" |
| 207 | + : null; |
| 208 | + |
| 209 | + final String carBudgetText = _carBudgetController.text.trim(); |
| 210 | + final int? carBudgetValue = int.tryParse(carBudgetText); |
| 211 | + final String? carBudgetError = |
| 212 | + _carBudgetTouched && |
| 213 | + (carBudgetText.isEmpty || |
| 214 | + carBudgetValue == null || |
| 215 | + carBudgetValue < _minCarBudget) |
| 216 | + ? "Minimum budget is 20,000\u20AC" |
| 217 | + : null; |
| 218 | + |
| 219 | + return Column( |
| 220 | + crossAxisAlignment: CrossAxisAlignment.stretch, |
| 221 | + children: [ |
| 222 | + const ShopInBitStep4Header( |
| 223 | + title: "Car Research request", |
| 224 | + subtitle: "Tell us about the car you're looking for.", |
| 225 | + ), |
| 226 | + SizedBox(height: isDesktop ? 32 : 24), |
| 227 | + ShopInBitCountryPicker( |
| 228 | + selectedIso: _selectedCountryIso, |
| 229 | + onChanged: (iso) => setState(() => _selectedCountryIso = iso), |
| 230 | + ), |
| 231 | + SizedBox(height: isDesktop ? 24 : 16), |
| 232 | + ShopInBitStep4TextField( |
| 233 | + controller: _brandController, |
| 234 | + focusNode: _brandFocusNode, |
| 235 | + hintText: "Car brand (e.g., BMW, Mercedes, Toyota...)", |
| 236 | + errorText: brandError, |
| 237 | + onChanged: (_) => setState(() {}), |
| 238 | + ), |
| 239 | + SizedBox(height: isDesktop ? 24 : 16), |
| 240 | + ShopInBitStep4TextField( |
| 241 | + controller: _modelController, |
| 242 | + focusNode: _modelFocusNode, |
| 243 | + hintText: "Car model (e.g., 3 Series, E-Class, Camry...)", |
| 244 | + errorText: modelError, |
| 245 | + onChanged: (_) => setState(() {}), |
| 246 | + ), |
| 247 | + SizedBox(height: isDesktop ? 24 : 16), |
| 248 | + ShopInBitStep4Dropdown( |
| 249 | + value: _selectedCarCondition, |
| 250 | + items: _carConditions, |
| 251 | + hintText: "Condition", |
| 252 | + onChanged: (value) => setState(() => _selectedCarCondition = value), |
| 253 | + ), |
| 254 | + SizedBox(height: isDesktop ? 24 : 16), |
| 255 | + ShopInBitStep4TextField( |
| 256 | + controller: _carDescriptionController, |
| 257 | + focusNode: _carDescriptionFocusNode, |
| 258 | + hintText: |
| 259 | + "Describe your requirements " |
| 260 | + "(year, mileage, features...)", |
| 261 | + minLines: 3, |
| 262 | + maxLines: 6, |
| 263 | + errorText: carDescriptionError, |
| 264 | + onChanged: (_) => setState(() {}), |
| 265 | + ), |
| 266 | + SizedBox(height: isDesktop ? 24 : 16), |
| 267 | + ShopInBitStep4TextField( |
| 268 | + controller: _carBudgetController, |
| 269 | + focusNode: _carBudgetFocusNode, |
| 270 | + hintText: "Budget (\u20AC, minimum 20,000)", |
| 271 | + keyboardType: TextInputType.number, |
| 272 | + inputFormatters: [FilteringTextInputFormatter.digitsOnly], |
| 273 | + suffixText: "\u20AC", |
| 274 | + errorText: carBudgetError, |
| 275 | + onChanged: (_) => setState(() {}), |
| 276 | + ), |
| 277 | + SizedBox(height: isDesktop ? 24 : 16), |
| 278 | + _CarResearchFeeInfo(isDesktop: isDesktop), |
| 279 | + SizedBox(height: isDesktop ? 16 : 12), |
| 280 | + ShopInBitLabeledCheckbox( |
| 281 | + value: _feeAcknowledged, |
| 282 | + onChanged: (v) => setState(() => _feeAcknowledged = v), |
| 283 | + label: "I acknowledge the \u20AC223 research fee", |
| 284 | + ), |
| 285 | + SizedBox(height: isDesktop ? 16 : 12), |
| 286 | + ShopInBitPrivacyCheckbox( |
| 287 | + value: _privacyAccepted, |
| 288 | + onChanged: (v) => setState(() => _privacyAccepted = v), |
| 289 | + ), |
| 290 | + SizedBox(height: isDesktop ? 16 : 12), |
| 291 | + ShopInBitStep4SubmitButton( |
| 292 | + submitting: _submitting, |
| 293 | + enabled: _canContinue, |
| 294 | + onPressed: _submit, |
| 295 | + ), |
| 296 | + ], |
| 297 | + ); |
| 298 | + } |
| 299 | +} |
| 300 | + |
| 301 | +/// Info box showing the €223 (incl. VAT) research fee disclosure. |
| 302 | +class _CarResearchFeeInfo extends StatelessWidget { |
| 303 | + const _CarResearchFeeInfo({required this.isDesktop}); |
| 304 | + |
| 305 | + final bool isDesktop; |
| 306 | + |
| 307 | + @override |
| 308 | + Widget build(BuildContext context) { |
| 309 | + final TextStyle baseStyle = isDesktop |
| 310 | + ? STextStyles.desktopTextSmall(context) |
| 311 | + : STextStyles.w500_14(context); |
| 312 | + |
| 313 | + return RoundedWhiteContainer( |
| 314 | + child: Row( |
| 315 | + crossAxisAlignment: CrossAxisAlignment.center, |
| 316 | + children: [ |
| 317 | + Icon( |
| 318 | + Icons.info_outline, |
| 319 | + size: 20, |
| 320 | + color: Theme.of( |
| 321 | + context, |
| 322 | + ).extension<StackColors>()!.textFieldActiveSearchIconLeft, |
| 323 | + ), |
| 324 | + const SizedBox(width: 12), |
| 325 | + Expanded( |
| 326 | + child: RichText( |
| 327 | + text: TextSpan( |
| 328 | + style: baseStyle, |
| 329 | + children: [ |
| 330 | + TextSpan( |
| 331 | + text: "Research fee: ", |
| 332 | + style: baseStyle.copyWith(fontWeight: FontWeight.bold), |
| 333 | + ), |
| 334 | + const TextSpan( |
| 335 | + text: |
| 336 | + "\u20AC223 (incl. VAT): one-time payment, " |
| 337 | + "credited toward your purchase.", |
| 338 | + ), |
| 339 | + ], |
| 340 | + ), |
| 341 | + ), |
| 342 | + ), |
| 343 | + ], |
| 344 | + ), |
| 345 | + ); |
| 346 | + } |
| 347 | +} |
0 commit comments