Skip to content

Commit ed9ee6b

Browse files
authored
feat: Editable brands (#6203)
* Brands are editable * Animation * Reverse duration
1 parent bdd1aa7 commit ed9ee6b

File tree

5 files changed

+304
-29
lines changed

5 files changed

+304
-29
lines changed

packages/smooth_app/lib/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,14 @@
16081608
"edit_product_form_item_add_valid_item_tooltip": "Add",
16091609
"edit_product_form_item_add_invalid_item_tooltip": "Please enter a text first",
16101610
"edit_product_form_item_remove_item_tooltip": "Remove",
1611+
"edit_product_form_item_save_edit_item_tooltip": "Save your edit",
1612+
"@edit_product_form_item_save_edit_item_tooltip": {
1613+
"description": "The user can edit an existing item. This action will save the change."
1614+
},
1615+
"edit_product_form_item_cancel_edit_item_tooltip": "Cancel your edit",
1616+
"@edit_product_form_item_cancel_edit_item_tooltip": {
1617+
"description": "The user can edit an existing item. This action will cancel the change (and return to the initial value)."
1618+
},
16111619
"edit_product_form_item_packaging_title": "Recycling instructions photo",
16121620
"@edit_product_form_item_packaging_title": {
16131621
"description": "Product edition - Packaging - Title"

packages/smooth_app/lib/l10n/app_fr.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,6 +1600,14 @@
16001600
"edit_product_form_item_add_valid_item_tooltip": "Ajouter",
16011601
"edit_product_form_item_add_invalid_item_tooltip": "Veuillez d'abord saisir un texte",
16021602
"edit_product_form_item_remove_item_tooltip": "Retirer",
1603+
"edit_product_form_item_save_edit_item_tooltip": "Enregistrer votre modification",
1604+
"@edit_product_form_item_save_edit_item_tooltip": {
1605+
"description": "The user can edit an existing item. This action will save the change."
1606+
},
1607+
"edit_product_form_item_cancel_edit_item_tooltip": "Annuler votre modification",
1608+
"@edit_product_form_item_cancel_edit_item_tooltip": {
1609+
"description": "The user can edit an existing item. This action will cancel the change (and return to the initial value)."
1610+
},
16031611
"edit_product_form_item_packaging_title": "Photo des informations de recyclage",
16041612
"@edit_product_form_item_packaging_title": {
16051613
"description": "Product edition - Packaging - Title"

packages/smooth_app/lib/pages/product/simple_input_page_helpers.dart

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ abstract class AbstractSimpleInputPageHelper extends ChangeNotifier {
3838
/// Is the list of terms reorderable?
3939
bool get reorderable => false;
4040

41+
/// Are items editable?
42+
bool get editable => false;
43+
4144
/// Returns the terms as they were initially in the product.
4245
///
4346
/// WARNING: this list must be copied; if not you may alter the product.
@@ -212,6 +215,12 @@ abstract class AbstractSimpleInputPageHelper extends ChangeNotifier {
212215
notifyListeners();
213216
}
214217

218+
void replaceItem(int position, String term) {
219+
_terms[position] = term;
220+
_changed = true;
221+
notifyListeners();
222+
}
223+
215224
/// Returns the enum to be used for matomo analytics.
216225
AnalyticsEditEvents getAnalyticsEditEvent();
217226

@@ -227,6 +236,9 @@ class SimpleInputPageBrandsHelper extends AbstractSimpleInputPageHelper {
227236
@override
228237
bool get reorderable => true;
229238

239+
@override
240+
bool get editable => true;
241+
230242
@override
231243
List<String> initTerms(final Product product) => splitString(product.brands);
232244

@@ -250,7 +262,7 @@ class SimpleInputPageBrandsHelper extends AbstractSimpleInputPageHelper {
250262

251263
@override
252264
String getTypeLabel(AppLocalizations appLocalizations) =>
253-
appLocalizations.brand_names;
265+
appLocalizations.brand_name;
254266

255267
@override
256268
TagType? getTagType() => null;

packages/smooth_app/lib/pages/product/simple_input_widget.dart

Lines changed: 268 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
55
import 'package:openfoodfacts/openfoodfacts.dart';
66
import 'package:smooth_app/generic_lib/bottom_sheets/smooth_bottom_sheet.dart';
77
import 'package:smooth_app/generic_lib/design_constants.dart';
8+
import 'package:smooth_app/generic_lib/duration_constants.dart';
89
import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
910
import 'package:smooth_app/generic_lib/widgets/smooth_snackbar.dart';
1011
import 'package:smooth_app/helpers/collections_helper.dart';
@@ -13,7 +14,10 @@ import 'package:smooth_app/pages/product/explanation_widget.dart';
1314
import 'package:smooth_app/pages/product/owner_field_info.dart';
1415
import 'package:smooth_app/pages/product/simple_input_page_helpers.dart';
1516
import 'package:smooth_app/pages/product/simple_input_text_field.dart';
17+
import 'package:smooth_app/pages/text_field_helper.dart';
1618
import 'package:smooth_app/resources/app_icons.dart' as icons;
19+
import 'package:smooth_app/themes/smooth_theme.dart';
20+
import 'package:smooth_app/themes/smooth_theme_colors.dart';
1721
import 'package:smooth_app/themes/theme_provider.dart';
1822

1923
/// Simple input widget: we have a list of terms, we add, we remove.
@@ -271,34 +275,15 @@ class _SimpleInputWidgetState extends State<SimpleInputWidget> {
271275
BuildContext context,
272276
int position,
273277
) {
274-
final AppLocalizations appLocalizations = AppLocalizations.of(context);
275-
276-
final String term = _localTerms[position];
277-
final Text child = Text(term);
278-
279-
return ListTile(
280-
leading: widget.helper.reorderable
281-
? ReorderableDelayedDragStartListener(
282-
index: position,
283-
child: const icons.Menu.hamburger(),
284-
)
285-
: null,
286-
trailing: Tooltip(
287-
message: appLocalizations.edit_product_form_item_remove_item_tooltip,
288-
child: InkWell(
289-
customBorder: const CircleBorder(),
290-
onTap: () => _onRemoveItem(term, child),
291-
child: const Padding(
292-
padding: EdgeInsets.all(SMALL_SPACE),
293-
child: Icon(Icons.delete),
294-
),
295-
),
296-
),
297-
contentPadding: const EdgeInsetsDirectional.only(
298-
start: LARGE_SPACE,
299-
),
300-
minTileHeight: 48.0,
301-
title: child,
278+
return _SimpleInputListItem(
279+
term: _localTerms[position],
280+
reorderable: widget.helper.reorderable,
281+
editable: widget.helper.editable,
282+
position: position,
283+
onChanged: (int position, String term) {
284+
widget.helper.replaceItem(position, term);
285+
},
286+
onRemoveItem: _onRemoveItem,
302287
);
303288
}
304289

@@ -412,3 +397,258 @@ class ExplanationTitleIcon extends StatelessWidget {
412397
);
413398
}
414399
}
400+
401+
class _SimpleInputListItem extends StatefulWidget {
402+
const _SimpleInputListItem({
403+
required this.term,
404+
required this.reorderable,
405+
required this.editable,
406+
required this.position,
407+
required this.onChanged,
408+
required this.onRemoveItem,
409+
});
410+
411+
final String term;
412+
final bool reorderable;
413+
final bool editable;
414+
final int position;
415+
final Function(int position, String term) onChanged;
416+
final Function(String term, Widget child) onRemoveItem;
417+
418+
@override
419+
State<_SimpleInputListItem> createState() => _SimpleInputListItemState();
420+
}
421+
422+
class _SimpleInputListItemState extends State<_SimpleInputListItem> {
423+
late final TextEditingControllerWithHistory _controller;
424+
late final FocusNode _focusNode;
425+
426+
bool _isEditing = false;
427+
428+
@override
429+
void initState() {
430+
super.initState();
431+
432+
if (widget.editable) {
433+
_controller = TextEditingControllerWithHistory(text: widget.term);
434+
_focusNode = FocusNode()..addListener(_onFocus);
435+
}
436+
}
437+
438+
@override
439+
Widget build(BuildContext context) {
440+
final AppLocalizations appLocalizations = AppLocalizations.of(context);
441+
final SmoothColorsThemeExtension extension =
442+
context.extension<SmoothColorsThemeExtension>();
443+
444+
Widget child;
445+
if (widget.editable) {
446+
child = _getEditableItem();
447+
} else {
448+
child = _getItem();
449+
}
450+
451+
child = ListTile(
452+
leading: widget.reorderable
453+
? ReorderableDelayedDragStartListener(
454+
index: widget.position,
455+
child: const icons.Menu.hamburger(),
456+
)
457+
: null,
458+
trailing: Row(
459+
mainAxisSize: MainAxisSize.min,
460+
children: <Widget>[
461+
if (widget.editable) ...<Widget>[
462+
_SimpleInputListItemAction(
463+
tooltip: appLocalizations
464+
.edit_product_form_item_save_edit_item_tooltip,
465+
icon: Icon(
466+
Icons.check_circle_rounded,
467+
color: extension.success,
468+
),
469+
visible: _isEditing,
470+
onTap: _saveEdit,
471+
),
472+
_SimpleInputListItemAction(
473+
tooltip: appLocalizations
474+
.edit_product_form_item_cancel_edit_item_tooltip,
475+
icon: Icon(
476+
Icons.cancel,
477+
color: extension.error,
478+
),
479+
visible: _isEditing,
480+
onTap: _cancelEdit,
481+
)
482+
],
483+
_SimpleInputListItemAction(
484+
tooltip:
485+
appLocalizations.edit_product_form_item_remove_item_tooltip,
486+
icon: const Icon(Icons.delete),
487+
onTap: () => widget.onRemoveItem(widget.term, child),
488+
visible: !_isEditing,
489+
),
490+
],
491+
),
492+
contentPadding: const EdgeInsetsDirectional.only(
493+
start: LARGE_SPACE,
494+
),
495+
minTileHeight: 48.0,
496+
title: child,
497+
);
498+
499+
if (widget.editable) {
500+
return ClipRRect(child: child);
501+
} else {
502+
return child;
503+
}
504+
}
505+
506+
Widget _getItem() {
507+
return Text(widget.term);
508+
}
509+
510+
Widget _getEditableItem() {
511+
return TextField(
512+
controller: _controller,
513+
focusNode: _focusNode,
514+
style: TextTheme.of(context).bodyLarge,
515+
decoration: const InputDecoration(
516+
isDense: true,
517+
border: InputBorder.none,
518+
contentPadding: EdgeInsets.zero,
519+
),
520+
maxLines: 1,
521+
onEditingComplete: _saveEdit,
522+
);
523+
}
524+
525+
void _saveEdit() {
526+
if (_controller.text.trim().isEmpty) {
527+
widget.onRemoveItem(widget.term, _getItem());
528+
} else {
529+
widget.onChanged(widget.position, _controller.text);
530+
}
531+
532+
setState(() => _isEditing = false);
533+
_focusNode.unfocus();
534+
}
535+
536+
void _cancelEdit() {
537+
_controller.resetToInitialValue();
538+
_focusNode.unfocus();
539+
setState(() => _isEditing = false);
540+
}
541+
542+
void _onFocus() {
543+
if (_focusNode.hasFocus && !_isEditing) {
544+
setState(() => _isEditing = true);
545+
} else if (!_focusNode.hasFocus && _isEditing) {
546+
_cancelEdit();
547+
}
548+
}
549+
550+
@override
551+
void dispose() {
552+
if (widget.editable) {
553+
_controller.dispose();
554+
}
555+
super.dispose();
556+
}
557+
}
558+
559+
class _SimpleInputListItemAction extends StatefulWidget {
560+
const _SimpleInputListItemAction({
561+
required this.onTap,
562+
required this.icon,
563+
required this.tooltip,
564+
this.visible = true,
565+
});
566+
567+
final VoidCallback onTap;
568+
final Widget icon;
569+
final String tooltip;
570+
final bool visible;
571+
572+
@override
573+
State<_SimpleInputListItemAction> createState() =>
574+
_SimpleInputListItemActionState();
575+
}
576+
577+
class _SimpleInputListItemActionState extends State<_SimpleInputListItemAction>
578+
with SingleTickerProviderStateMixin {
579+
late AnimationController _controller;
580+
late Animation<double> _opacityAnimation;
581+
late Animation<double> _sizeAnimation;
582+
583+
@override
584+
void initState() {
585+
super.initState();
586+
587+
_controller = AnimationController(
588+
vsync: this,
589+
duration: SmoothAnimationsDuration.medium,
590+
reverseDuration: SmoothAnimationsDuration.short,
591+
)..addListener(() => setState(() {}));
592+
593+
if (widget.visible) {
594+
_controller.forward(from: 1.0);
595+
}
596+
597+
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
598+
CurvedAnimation(
599+
parent: _controller,
600+
curve: Curves.easeInOut,
601+
),
602+
);
603+
604+
_sizeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
605+
CurvedAnimation(
606+
parent: _controller,
607+
curve: const Interval(0.2, 0.8, curve: Curves.easeInOut),
608+
),
609+
);
610+
}
611+
612+
@override
613+
void didUpdateWidget(_SimpleInputListItemAction oldWidget) {
614+
super.didUpdateWidget(oldWidget);
615+
616+
if (widget.visible != oldWidget.visible) {
617+
if (widget.visible) {
618+
_controller.forward(from: 0.0);
619+
} else {
620+
_controller.reverse(from: 1.0);
621+
}
622+
}
623+
}
624+
625+
@override
626+
Widget build(BuildContext context) {
627+
return Offstage(
628+
offstage: _controller.value == 0.0,
629+
child: Opacity(
630+
opacity: _opacityAnimation.value,
631+
child: SizedBox(
632+
width: _sizeAnimation.value * (SMALL_SPACE * 2 + 24.0),
633+
child: Tooltip(
634+
message: widget.tooltip,
635+
child: InkWell(
636+
customBorder: const CircleBorder(),
637+
onTap: widget.onTap,
638+
child: Padding(
639+
padding: const EdgeInsets.all(SMALL_SPACE),
640+
child: widget.icon,
641+
),
642+
),
643+
),
644+
),
645+
),
646+
);
647+
}
648+
649+
@override
650+
void dispose() {
651+
_controller.dispose();
652+
super.dispose();
653+
}
654+
}

0 commit comments

Comments
 (0)