TF-4301 Add "Create a new label" button in Label As menus and wire up tagging in email view

This commit is contained in:
dab246
2026-02-04 13:53:01 +07:00
committed by Dat H. Pham
parent 061793846e
commit 190ccc7bc3
29 changed files with 600 additions and 247 deletions
@@ -2,13 +2,16 @@ import 'dart:math' as math;
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/utils/theme_utils.dart';
import 'package:core/presentation/views/button/default_close_button_widget.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:core/utils/platform_info.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jmap_dart_client/jmap/mail/email/email.dart';
import 'package:labels/labels.dart';
import 'package:tmail_ui_user/features/labels/presentation/widgets/label_item_context_menu.dart';
import 'package:tmail_ui_user/features/labels/presentation/widgets/label_list_context_menu.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
@@ -23,6 +26,7 @@ class AddLabelToEmailModal extends StatefulWidget {
final List<Label> emailLabels;
final List<EmailId> emailIds;
final OnAddLabelToEmailsCallback onAddLabelToEmailsCallback;
final OnCreateANewLabelAction onCreateANewLabelAction;
const AddLabelToEmailModal({
super.key,
@@ -30,6 +34,7 @@ class AddLabelToEmailModal extends StatefulWidget {
required this.emailLabels,
required this.emailIds,
required this.onAddLabelToEmailsCallback,
required this.onCreateANewLabelAction,
});
@override
@@ -95,29 +100,46 @@ class _AddLabelToEmailModalState extends State<AddLabelToEmailModal> {
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: Container(
padding: const EdgeInsetsDirectional.only(
start: 22,
end: 22,
bottom: 16,
),
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: widget.labels.length,
itemBuilder: (context, index) {
final label = widget.labels[index];
final isSelected = widget.emailLabels.contains(label);
return LabelItemContextMenu(
label: label,
imagePaths: _imagePaths,
isSelected: isSelected,
onSelectLabelAction: _onSelectLabel,
);
},
if (widget.labels.isNotEmpty)
Flexible(
child: Container(
padding: const EdgeInsetsDirectional.symmetric(
horizontal: 22,
),
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: widget.labels.length,
itemBuilder: (context, index) {
final label = widget.labels[index];
final isSelected = widget.emailLabels.contains(label);
return LabelItemContextMenu(
label: label,
imagePaths: _imagePaths,
isSelected: isSelected,
onSelectLabelAction: _onSelectLabel,
);
},
),
),
),
TMailButtonWidget(
text: appLocalizations.createANewLabel,
icon: _imagePaths.icAddNewFolder,
iconColor: AppColor.primaryMain,
iconSize: 18,
backgroundColor: Colors.transparent,
height: 48,
flexibleText: true,
mainAxisSize: MainAxisSize.min,
margin: const EdgeInsetsDirectional.only(
bottom: 16,
start: 22,
),
textStyle: ThemeUtils.textStyleM3LabelLarge(
color: AppColor.primaryMain,
),
onTapActionCallback: widget.onCreateANewLabelAction,
),
],
),
@@ -1,19 +1,30 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/extensions/hex_color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/presentation/utils/responsive_utils.dart';
import 'package:core/presentation/utils/theme_utils.dart';
import 'package:core/presentation/views/button/default_close_button_widget.dart';
import 'package:core/presentation/views/color/color_picker_modal.dart';
import 'package:core/presentation/views/color/colors_map_widget.dart';
import 'package:core/presentation/views/dialog/modal_list_action_button_widget.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/platform_info.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:labels/labels.dart';
import 'package:tmail_ui_user/features/base/widget/label_input_field_builder.dart';
import 'package:tmail_ui_user/features/labels/domain/exceptions/label_exceptions.dart';
import 'package:tmail_ui_user/features/labels/domain/model/edit_label_request.dart';
import 'package:tmail_ui_user/features/labels/domain/state/create_new_label_state.dart';
import 'package:tmail_ui_user/features/labels/domain/state/edit_label_state.dart';
import 'package:tmail_ui_user/features/labels/domain/usecases/create_new_label_interactor.dart';
import 'package:tmail_ui_user/features/labels/domain/usecases/edit_label_interactor.dart';
import 'package:tmail_ui_user/features/labels/presentation/models/label_action_type.dart';
import 'package:tmail_ui_user/features/mailbox_creator/domain/model/verification/duplicate_name_validator.dart';
import 'package:tmail_ui_user/features/mailbox_creator/domain/model/verification/empty_name_validator.dart';
@@ -27,16 +38,30 @@ import 'package:tmail_ui_user/main/routes/route_navigation.dart';
typedef OnLabelActionCallback = Function(Label label);
enum LabelPositiveButtonState {
enabled,
disabled,
progressing;
}
class CreateNewLabelModal extends StatefulWidget {
final List<Label> labels;
final AccountId accountId;
final ImagePaths imagePaths;
final LabelActionType actionType;
final OnLabelActionCallback onLabelActionCallback;
final CreateNewLabelInteractor createNewLabelInteractor;
final EditLabelInteractor editLabelInteractor;
final VerifyNameInteractor verifyNameInteractor;
final Label? selectedLabel;
const CreateNewLabelModal({
super.key,
required this.labels,
required this.onLabelActionCallback,
required this.accountId,
required this.imagePaths,
required this.createNewLabelInteractor,
required this.editLabelInteractor,
required this.verifyNameInteractor,
this.actionType = LabelActionType.create,
this.selectedLabel,
});
@@ -46,13 +71,11 @@ class CreateNewLabelModal extends StatefulWidget {
}
class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
final _imagePaths = Get.find<ImagePaths>();
final _verifyNameInteractor = Get.find<VerifyNameInteractor>();
final ValueNotifier<String?> _labelNameErrorTextNotifier =
ValueNotifier(null);
final ValueNotifier<Color?> _labelSelectedColorNotifier = ValueNotifier(null);
final ValueNotifier<bool> _createLabelStateNotifier = ValueNotifier(false);
final ValueNotifier<LabelPositiveButtonState> _createLabelStateNotifier =
ValueNotifier(LabelPositiveButtonState.disabled);
final TextEditingController _nameInputController = TextEditingController();
final FocusNode _nameInputFocusNode = FocusNode();
final TextEditingController _descriptionInputController =
@@ -61,6 +84,7 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
List<String> _labelDisplayNameList = <String>[];
Color? _selectedColor;
StreamSubscription? _streamSubscription;
@override
void initState() {
@@ -69,8 +93,8 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
final labels = widget.labels;
if (selectedLabel != null) {
_selectedColor = selectedLabel.color?.value.toColor();
_labelDisplayNameList = labels
.getDisplayNameListWithoutSelectedLabel(selectedLabel);
_labelDisplayNameList =
labels.getDisplayNameListWithoutSelectedLabel(selectedLabel);
} else {
_labelDisplayNameList = labels.displayNameNotNullList;
}
@@ -79,7 +103,7 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
_nameInputController.text = selectedLabel.safeDisplayName;
_nameInputFocusNode.requestFocus();
_descriptionInputController.text = selectedLabel.safeDescription;
_createLabelStateNotifier.value = true;
_createLabelStateNotifier.value = LabelPositiveButtonState.enabled;
_labelSelectedColorNotifier.value = _selectedColor;
}
});
@@ -170,7 +194,7 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
valueListenable: _labelSelectedColorNotifier,
builder: (_, value, __) {
return ColorsMapWidget(
imagePaths: _imagePaths,
imagePaths: widget.imagePaths,
customColor: value,
onOpenColorPicker: () =>
_openColorPickerModal(appLocalizations),
@@ -182,17 +206,22 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
),
ValueListenableBuilder(
valueListenable: _createLabelStateNotifier,
builder: (_, value, __) {
builder: (_, state, __) {
return ModalListActionButtonWidget(
positiveLabel: widget.actionType.getModalPositiveAction(appLocalizations),
positiveLabel: widget.actionType
.getModalPositiveAction(appLocalizations),
negativeLabel: appLocalizations.cancel,
padding: const EdgeInsets.symmetric(
vertical: 25,
),
isPositiveActionEnabled: value,
isPositiveActionEnabled:
state == LabelPositiveButtonState.enabled,
isProgressing: state ==
LabelPositiveButtonState.progressing,
onPositiveAction: _onCreateNewLabel,
onNegativeAction: _onCloseModal,
positiveKey: widget.actionType.getModalPositiveActionKey(),
positiveKey: widget.actionType
.getModalPositiveActionKey(),
);
},
),
@@ -204,7 +233,7 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
],
),
DefaultCloseButtonWidget(
iconClose: _imagePaths.icCloseDialog,
iconClose: widget.imagePaths.icCloseDialog,
onTapActionCallback: _onCloseModal,
),
],
@@ -283,26 +312,23 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
return LabelInputFieldBuilder(
key: const Key('label_name_input_field'),
label: appLocalizations.labelName,
hintText: appLocalizations
.pleaseEnterNameYourNewLabel,
hintText: appLocalizations.pleaseEnterNameYourNewLabel,
textEditingController: _nameInputController,
focusNode: _nameInputFocusNode,
errorText: errorText,
arrangeHorizontally: false,
isLabelHasColon: false,
labelStyle:
ThemeUtils.textStyleInter600().copyWith(
labelStyle: ThemeUtils.textStyleInter600().copyWith(
fontSize: 14,
height: 18 / 14,
color: Colors.black,
),
runSpacing: 16,
inputFieldMaxWidth: double.infinity,
onTextChange: (value) =>
_onLabelNameInputChanged(
appLocalizations,
value,
),
onTextChange: (value) => _onLabelNameInputChanged(
appLocalizations,
value,
),
);
},
);
@@ -338,11 +364,13 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
) {
final errorText = _verifyLabelName(appLocalizations, value);
_labelNameErrorTextNotifier.value = errorText;
_createLabelStateNotifier.value = errorText == null;
_createLabelStateNotifier.value = errorText == null
? LabelPositiveButtonState.enabled
: LabelPositiveButtonState.disabled;
}
String? _verifyLabelName(AppLocalizations appLocalizations, String value) {
final result = _verifyNameInteractor.execute(
final result = widget.verifyNameInteractor.execute(
value,
_validators,
);
@@ -369,6 +397,8 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
void _onCreateNewLabel() {
_clearInputFocus();
_createLabelStateNotifier.value = LabelPositiveButtonState.progressing;
final newLabel = Label(
displayName: _nameInputController.text,
color: _selectedColor != null
@@ -378,9 +408,17 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
? null
: _descriptionInputController.text.trim(),
);
widget.onLabelActionCallback(newLabel);
popBack();
switch (widget.actionType) {
case LabelActionType.create:
_performCreateLabel(newLabel);
break;
case LabelActionType.edit:
_performEditLabel(newLabel);
break;
case LabelActionType.delete:
break;
}
}
void _onCloseModal() {
@@ -402,7 +440,7 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
barrierDismissible: true,
barrierLabel: 'color-picker-modal',
pageBuilder: (_, __, ___) => ColorPickerModal(
imagePaths: _imagePaths,
imagePaths: widget.imagePaths,
modalTitle: appLocalizations.chooseCustomColour,
modalSubtitle: appLocalizations.chooseAColourForThisLabel,
initialColor: _selectedColor,
@@ -411,6 +449,64 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
);
}
void _performCreateLabel(Label newLabel) {
_streamSubscription = widget.createNewLabelInteractor
.execute(widget.accountId, newLabel)
.listen(_handleDataStream, onError: _handleErrorStream);
}
void _handleDataStream(dartz.Either<Failure, Success> newState) {
newState.fold((failure) {
if (failure is CreateNewLabelFailure || failure is EditLabelFailure) {
popBack(result: failure);
}
}, (success) {
if (success is CreateNewLabelSuccess || success is EditLabelSuccess) {
popBack(result: success);
}
});
}
void _handleErrorStream(Object error, StackTrace stackTrace) {
logWarning(
'CreateNewLabelModal::_handleErrorStream: Error: $error, StackTrace: $stackTrace');
switch (widget.actionType) {
case LabelActionType.create:
popBack(result: CreateNewLabelFailure(error));
break;
case LabelActionType.edit:
popBack(result: EditLabelFailure(error));
break;
case LabelActionType.delete:
break;
}
}
void _performEditLabel(Label newLabel) {
final currentLabelId = widget.selectedLabel?.id;
if (currentLabelId == null) {
popBack(result: EditLabelFailure(const LabelIdIsNull()));
return;
}
final currentLabelKeyword = widget.selectedLabel?.keyword;
if (currentLabelKeyword == null) {
popBack(result: EditLabelFailure(const LabelKeywordIsNull()));
return;
}
final labelRequest = EditLabelRequest(
labelId: currentLabelId,
labelKeyword: currentLabelKeyword,
newLabel: newLabel,
);
_streamSubscription = widget.editLabelInteractor
.execute(widget.accountId, labelRequest)
.listen(_handleDataStream, onError: _handleErrorStream);
}
@override
void dispose() {
_nameInputFocusNode.dispose();
@@ -421,6 +517,8 @@ class _CreateNewLabelModalState extends State<CreateNewLabelModal> {
_labelSelectedColorNotifier.dispose();
_createLabelStateNotifier.dispose();
_labelDisplayNameList = [];
_streamSubscription?.cancel();
_streamSubscription = null;
super.dispose();
}
}
@@ -1,13 +1,20 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/utils/theme_utils.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:labels/model/label.dart';
import 'package:tmail_ui_user/features/labels/presentation/widgets/label_item_context_menu.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
typedef OnCreateANewLabelAction = void Function();
class LabelListContextMenu extends StatelessWidget {
final List<Label> labelList;
final List<Label> emailLabels;
final ImagePaths imagePaths;
final OnSelectLabelAction onSelectLabelAction;
final OnCreateANewLabelAction onCreateANewLabelAction;
const LabelListContextMenu({
super.key,
@@ -15,23 +22,56 @@ class LabelListContextMenu extends StatelessWidget {
required this.emailLabels,
required this.imagePaths,
required this.onSelectLabelAction,
required this.onCreateANewLabelAction,
});
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
itemCount: labelList.length,
itemBuilder: (_, index) {
final label = labelList[index];
final isSelected = emailLabels.contains(label);
return LabelItemContextMenu(
label: label,
imagePaths: imagePaths,
isSelected: isSelected,
onSelectLabelAction: onSelectLabelAction,
);
},
final createLabelButton = TMailButtonWidget.fromText(
text: AppLocalizations.of(context).createANewLabel,
backgroundColor: Colors.transparent,
textStyle: ThemeUtils.textStyleBodyBody3(color: Colors.black),
width: double.infinity,
alignment: AlignmentDirectional.centerStart,
borderRadius: 0,
height: 48,
padding: const EdgeInsetsDirectional.symmetric(horizontal: 12),
margin: labelList.isNotEmpty
? const EdgeInsetsDirectional.only(bottom: 8)
: null,
onTapActionCallback: onCreateANewLabelAction,
);
if (labelList.isNotEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: labelList.length,
itemBuilder: (_, index) {
final label = labelList[index];
final isSelected = emailLabels.contains(label);
return LabelItemContextMenu(
label: label,
imagePaths: imagePaths,
isSelected: isSelected,
onSelectLabelAction: onSelectLabelAction,
);
},
),
),
Divider(
height: 1,
color: AppColor.gray424244.withValues(alpha: 0.12),
),
createLabelButton,
],
);
} else {
return createLabelButton;
}
}
}