Skip to content

Commit e45a26a

Browse files
authored
feat: replace static premium indicator with editable input and dynamic slider range (#454)
* feat: replace static premium indicator with editable input and dynamic slider range * coderabbit suggestion
1 parent 30a85c2 commit e45a26a

File tree

5 files changed

+220
-31
lines changed

5 files changed

+220
-31
lines changed

lib/features/order/widgets/form_section.dart

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,18 @@ class FormSection extends StatelessWidget {
7575
padding: const EdgeInsets.all(16),
7676
child: Row(
7777
children: [
78-
Container(
79-
width: 36,
80-
height: 36,
81-
decoration: BoxDecoration(
82-
color: iconBackgroundColor,
83-
shape: BoxShape.circle,
78+
if (iconBackgroundColor == Colors.transparent)
79+
icon
80+
else
81+
Container(
82+
width: 36,
83+
height: 36,
84+
decoration: BoxDecoration(
85+
color: iconBackgroundColor,
86+
shape: BoxShape.circle,
87+
),
88+
child: Center(child: icon),
8489
),
85-
child: Center(child: icon),
86-
),
8790
const SizedBox(width: 16),
8891
Expanded(child: child),
8992
],

lib/features/order/widgets/premium_section.dart

Lines changed: 203 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
4+
import 'package:flutter/services.dart';
25
import 'package:mostro_mobile/core/app_theme.dart';
36
import 'package:mostro_mobile/features/order/widgets/form_section.dart';
47
import 'package:mostro_mobile/generated/l10n.dart';
58

6-
class PremiumSection extends StatelessWidget {
9+
class PremiumSection extends StatefulWidget {
710
final double value;
811
final ValueChanged<double> onChanged;
912

@@ -13,25 +16,201 @@ class PremiumSection extends StatelessWidget {
1316
required this.onChanged,
1417
});
1518

19+
@override
20+
State<PremiumSection> createState() => _PremiumSectionState();
21+
}
22+
23+
class _PremiumSectionState extends State<PremiumSection> {
24+
late TextEditingController _controller;
25+
late FocusNode _focusNode;
26+
Timer? _debounceTimer;
27+
bool _isEditing = false;
28+
29+
static const double _defaultMin = -10;
30+
static const double _defaultMax = 10;
31+
static const double _absoluteMin = -100;
32+
static const double _absoluteMax = 100;
33+
34+
@override
35+
void initState() {
36+
super.initState();
37+
_controller = TextEditingController(text: widget.value.round().toString());
38+
_focusNode = FocusNode();
39+
_focusNode.addListener(_onFocusChanged);
40+
}
41+
42+
@override
43+
void didUpdateWidget(PremiumSection oldWidget) {
44+
super.didUpdateWidget(oldWidget);
45+
if (oldWidget.value != widget.value && !_isEditing) {
46+
_controller.text = widget.value.round().toString();
47+
}
48+
}
49+
50+
@override
51+
void dispose() {
52+
_debounceTimer?.cancel();
53+
_focusNode.removeListener(_onFocusChanged);
54+
_focusNode.dispose();
55+
_controller.dispose();
56+
super.dispose();
57+
}
58+
59+
void _onFocusChanged() {
60+
if (_focusNode.hasFocus) {
61+
setState(() => _isEditing = true);
62+
_controller.selection = TextSelection(
63+
baseOffset: 0,
64+
extentOffset: _controller.text.length,
65+
);
66+
} else {
67+
_debounceTimer?.cancel();
68+
_commitTextValue();
69+
setState(() => _isEditing = false);
70+
}
71+
}
72+
73+
void _onTextChanged(String text) {
74+
_debounceTimer?.cancel();
75+
_debounceTimer = Timer(const Duration(seconds: 2), () {
76+
_commitTextValue();
77+
});
78+
}
79+
80+
void _commitTextValue() {
81+
final text = _controller.text.trim();
82+
if (text.isEmpty || text == '-') {
83+
_controller.text = widget.value.round().toString();
84+
return;
85+
}
86+
final parsed = int.tryParse(text);
87+
if (parsed == null) {
88+
_controller.text = widget.value.round().toString();
89+
return;
90+
}
91+
final clamped = parsed.clamp(_absoluteMin.toInt(), _absoluteMax.toInt());
92+
widget.onChanged(clamped.toDouble());
93+
_controller.text = clamped.toString();
94+
}
95+
96+
double get _sliderMin => _defaultMin < widget.value ? _defaultMin : widget.value;
97+
double get _sliderMax => _defaultMax > widget.value ? _defaultMax : widget.value;
98+
99+
int get _sliderDivisions {
100+
final range = _sliderMax - _sliderMin;
101+
return range.round().clamp(1, 200);
102+
}
103+
16104
@override
17105
Widget build(BuildContext context) {
18-
// Define the premium value display as the icon - showing only whole numbers
19-
final premiumValueIcon = Text(
20-
value.round().toString(),
21-
style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14),
106+
final premiumInput = Column(
107+
mainAxisSize: MainAxisSize.min,
108+
children: [
109+
GestureDetector(
110+
onTap: () {
111+
_focusNode.requestFocus();
112+
},
113+
child: Stack(
114+
clipBehavior: Clip.none,
115+
children: [
116+
SizedBox(
117+
width: 52,
118+
height: 32,
119+
child: TextField(
120+
controller: _controller,
121+
focusNode: _focusNode,
122+
textAlign: TextAlign.center,
123+
style: const TextStyle(
124+
color: AppTheme.textPrimary,
125+
fontSize: 14,
126+
fontWeight: FontWeight.w600,
127+
),
128+
decoration: InputDecoration(
129+
contentPadding: const EdgeInsets.symmetric(
130+
horizontal: 4, vertical: 6),
131+
filled: true,
132+
fillColor: _isEditing
133+
? AppTheme.purpleButton.withValues(alpha: 0.8)
134+
: AppTheme.purpleButton,
135+
border: OutlineInputBorder(
136+
borderRadius: BorderRadius.circular(16),
137+
borderSide: _isEditing
138+
? const BorderSide(
139+
color: AppTheme.textPrimary, width: 1.5)
140+
: BorderSide.none,
141+
),
142+
enabledBorder: OutlineInputBorder(
143+
borderRadius: BorderRadius.circular(16),
144+
borderSide: BorderSide.none,
145+
),
146+
focusedBorder: OutlineInputBorder(
147+
borderRadius: BorderRadius.circular(16),
148+
borderSide: const BorderSide(
149+
color: AppTheme.textPrimary, width: 1.5),
150+
),
151+
),
152+
keyboardType:
153+
const TextInputType.numberWithOptions(signed: true),
154+
inputFormatters: [
155+
TextInputFormatter.withFunction((oldValue, newValue) {
156+
if (newValue.text.isEmpty) return newValue;
157+
return RegExp(r'^-?\d{0,3}$').hasMatch(newValue.text)
158+
? newValue
159+
: oldValue;
160+
}),
161+
],
162+
onChanged: _onTextChanged,
163+
onSubmitted: (_) {
164+
_debounceTimer?.cancel();
165+
_commitTextValue();
166+
_focusNode.unfocus();
167+
},
168+
),
169+
),
170+
if (!_isEditing)
171+
Positioned(
172+
right: -2,
173+
bottom: -2,
174+
child: Container(
175+
width: 16,
176+
height: 16,
177+
decoration: const BoxDecoration(
178+
color: AppTheme.textPrimary,
179+
shape: BoxShape.circle,
180+
),
181+
child: const Icon(
182+
Icons.edit,
183+
size: 9,
184+
color: AppTheme.purpleButton,
185+
),
186+
),
187+
),
188+
],
189+
),
190+
),
191+
const SizedBox(height: 4),
192+
Text(
193+
S.of(context)!.premiumEditHint,
194+
style: TextStyle(
195+
color: AppTheme.textSubtle,
196+
fontSize: 10,
197+
),
198+
),
199+
],
22200
);
23201

24-
// Use the FormSection for consistent styling
202+
final minLabel = '${_sliderMin.round()}%';
203+
final maxLabel = '+${_sliderMax.round()}%';
204+
25205
return FormSection(
26206
title: S.of(context)!.premiumTitle,
27-
icon: premiumValueIcon,
28-
iconBackgroundColor: AppTheme.purpleButton, // Purple color for premium
207+
icon: premiumInput,
208+
iconBackgroundColor: Colors.transparent,
29209
infoTooltip: S.of(context)!.premiumTooltip,
30210
infoTitle: S.of(context)!.premiumTitle,
31211
child: Column(
32212
crossAxisAlignment: CrossAxisAlignment.start,
33213
children: [
34-
// Slider
35214
SliderTheme(
36215
data: SliderThemeData(
37216
activeTrackColor: AppTheme.purpleButton,
@@ -42,26 +221,30 @@ class PremiumSection extends StatelessWidget {
42221
),
43222
child: Slider(
44223
key: const Key('premiumSlider'),
45-
value: value,
46-
min: -10,
47-
max: 10,
48-
divisions: 20,
49-
onChanged: onChanged,
224+
value: widget.value.clamp(_sliderMin, _sliderMax),
225+
min: _sliderMin,
226+
max: _sliderMax,
227+
divisions: _sliderDivisions,
228+
onChanged: (value) {
229+
widget.onChanged(value.roundToDouble());
230+
_controller.text = value.round().toString();
231+
},
50232
),
51233
),
52-
53234
Padding(
54235
padding: const EdgeInsets.symmetric(horizontal: 4),
55236
child: Row(
56237
mainAxisAlignment: MainAxisAlignment.spaceBetween,
57-
children: const [
238+
children: [
58239
Text(
59-
'-10%',
60-
style: TextStyle(color: AppTheme.statusError, fontSize: 12),
240+
minLabel,
241+
style: const TextStyle(
242+
color: AppTheme.statusError, fontSize: 12),
61243
),
62244
Text(
63-
'+10%',
64-
style: TextStyle(color: AppTheme.statusSuccess, fontSize: 12),
245+
maxLabel,
246+
style: const TextStyle(
247+
color: AppTheme.statusSuccess, fontSize: 12),
65248
),
66249
],
67250
),

lib/l10n/intl_en.arb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,8 @@
470470
"selectPaymentMethodsTitle": "Select Payment Methods",
471471
"premiumTitle": "Premium (%)",
472472
"premiumTooltip": "Adjust how much above or below the market price you want your offer. By default, it's set to 0%, with no premium or discount, so if you don't want to change the price, you can leave it as is.",
473-
473+
"premiumEditHint": "Edit",
474+
474475
"secretWords": "Secret Words",
475476
"toRestoreYourAccount": "To restore your account",
476477
"privacy": "Privacy",

lib/l10n/intl_es.arb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,8 @@
382382
"selectPaymentMethodsTitle": "Seleccionar Métodos de Pago",
383383
"premiumTitle": "Prima (%)",
384384
"premiumTooltip": "Ajusta cuánto por encima o por debajo del precio de mercado quieres tu oferta. Por defecto, está establecido en 0%, sin prima o descuento, así que si no quieres cambiar el precio, puedes dejarlo como está.",
385-
385+
"premiumEditHint": "Editar",
386+
386387
"secretWords": "Palabras Secretas",
387388
"toRestoreYourAccount": "Para restaurar tu cuenta",
388389
"privacy": "Privacidad",

lib/l10n/intl_it.arb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,8 @@
412412
"selectPaymentMethodsTitle": "Seleziona Metodi di Pagamento",
413413
"premiumTitle": "Premio (%)",
414414
"premiumTooltip": "Regola quanto sopra o sotto il prezzo di mercato vuoi la tua offerta. Di default, è impostato al 0%, senza premio o sconto, quindi se non vuoi cambiare il prezzo, puoi lasciarlo così com'è.",
415-
415+
"premiumEditHint": "Modifica",
416+
416417
"secretWords": "Parole Segrete",
417418
"toRestoreYourAccount": "Per ripristinare il tuo account",
418419
"privacy": "Privacy",

0 commit comments

Comments
 (0)