feat: Persist label state after get all labels

This commit is contained in:
dab246
2026-02-03 19:46:24 +07:00
committed by Dat H. Pham
parent 9c211c05ef
commit 4fea45d062
11 changed files with 187 additions and 152 deletions
@@ -6,7 +6,7 @@ import 'package:tmail_ui_user/features/labels/domain/model/edit_label_request.da
import 'package:tmail_ui_user/features/labels/data/model/label_change_response.dart';
abstract class LabelDatasource {
Future<List<Label>> getAllLabels(AccountId accountId);
Future<({List<Label> labels, State? newState})> getAllLabels(AccountId accountId);
Future<Label> createNewLabel(AccountId accountId, Label labelData);
@@ -15,7 +15,7 @@ class LabelDatasourceImpl extends LabelDatasource {
LabelDatasourceImpl(this._labelApi, this._exceptionThrower);
@override
Future<List<Label>> getAllLabels(AccountId accountId) {
Future<({List<Label> labels, State? newState})> getAllLabels(AccountId accountId) {
return Future.sync(() async {
return await _labelApi.getAllLabels(accountId);
}).catchError(_exceptionThrower.throwException);
@@ -24,7 +24,7 @@ class LabelApi
LabelApi(this._httpClient, this._uuid);
Future<List<Label>> getAllLabels(AccountId accountId) async {
Future<({List<Label> labels, State? newState})> getAllLabels(AccountId accountId) async {
final builder = JmapRequestBuilder(_httpClient, ProcessingInvocation());
final method = GetLabelMethod(accountId);
final invocation = builder.invocation(method);
@@ -37,7 +37,7 @@ class LabelApi
GetLabelResponse.deserialize,
);
return response?.list ?? [];
return (labels: response?.list ?? [], newState: response?.state);
}
Future<Label> createNewLabel(AccountId accountId, Label labelData) async {
@@ -14,7 +14,7 @@ class LabelRepositoryImpl extends LabelRepository {
LabelRepositoryImpl(this._labelDatasource);
@override
Future<List<Label>> getAllLabels(AccountId accountId) {
Future<({List<Label> labels, State? newState})> getAllLabels(AccountId accountId) {
return _labelDatasource.getAllLabels(accountId);
}
@@ -6,7 +6,7 @@ import 'package:tmail_ui_user/features/labels/domain/model/edit_label_request.da
import 'package:tmail_ui_user/features/labels/domain/model/label_changes_result.dart';
abstract class LabelRepository {
Future<List<Label>> getAllLabels(AccountId accountId);
Future<({List<Label> labels, State? newState})> getAllLabels(AccountId accountId);
Future<Label> createNewLabel(AccountId accountId, Label labelData);
@@ -1,16 +1,18 @@
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:jmap_dart_client/jmap/core/state.dart';
import 'package:labels/model/label.dart';
class GettingAllLabel extends LoadingState {}
class GetAllLabelSuccess extends UIState {
final List<Label> labels;
final State? newState;
GetAllLabelSuccess(this.labels);
GetAllLabelSuccess(this.labels, this.newState);
@override
List<Object> get props => [labels];
List<Object?> get props => [labels, newState];
}
class GetAllLabelFailure extends FeatureFailure {
@@ -13,8 +13,8 @@ class GetAllLabelInteractor {
Stream<Either<Failure, Success>> execute(AccountId accountId) async* {
try {
yield Right(GettingAllLabel());
final labels = await _labelRepository.getAllLabels(accountId);
yield Right(GetAllLabelSuccess(labels));
final result = await _labelRepository.getAllLabels(accountId);
yield Right(GetAllLabelSuccess(result.labels, result.newState));
} catch (e) {
yield Left(GetAllLabelFailure(e));
}
@@ -179,6 +179,7 @@ class LabelController extends BaseController with LabelContextMenuMixin {
void handleSuccessViewState(Success success) {
if (success is GetAllLabelSuccess) {
labels.value = success.labels..sortByAlphabetically();
setCurrentLabelState(success.newState);
} else if (success is CreateNewLabelSuccess) {
_handleCreateNewLabelSuccess(success);
} else if (success is GetLabelSettingStateSuccess) {
@@ -10,6 +10,7 @@ import 'package:tmail_ui_user/features/labels/domain/usecases/create_new_label_i
import 'package:tmail_ui_user/features/labels/domain/usecases/edit_label_interactor.dart';
import 'package:tmail_ui_user/features/labels/domain/usecases/delete_a_label_interactor.dart';
import 'package:tmail_ui_user/features/labels/domain/usecases/get_all_label_interactor.dart';
import 'package:tmail_ui_user/features/labels/domain/usecases/get_label_changes_interactor.dart';
import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart';
import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart';
import 'package:uuid/uuid.dart';
@@ -37,6 +38,7 @@ class LabelInteractorBindings extends InteractorsBindings {
Get.lazyPut(() => CreateNewLabelInteractor(Get.find<LabelRepository>()));
Get.lazyPut(() => EditLabelInteractor(Get.find<LabelRepository>()));
Get.lazyPut(() => DeleteALabelInteractor(Get.find<LabelRepository>()));
Get.lazyPut(() => GetLabelChangesInteractor(Get.find<LabelRepository>()));
Get.lazyPut(() => VerifyNameInteractor());
}
@@ -252,6 +252,7 @@ class LabelUtils {
}) {
final idsToRemove = {
...destroyedIds,
...created.map((label) => label.id),
...updated.map((label) => label.id),
};
+118 -89
View File
@@ -4,10 +4,15 @@ import 'package:labels/model/label.dart';
import 'package:tmail_ui_user/features/labels/presentation/utils/label_utils.dart';
void main() {
Label createLabel(String id, String name) {
return Label(id: Id(id), displayName: name);
}
group('LabelUtils.applyLabelChanges', () {
group('Create Operations', () {
test('should add new labels when created list is not empty', () {
final currentLabels = <Label>[];
final newLabel = Label(id: Id('1'), displayName: 'Work');
final newLabel = createLabel('1', 'Work');
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
@@ -21,12 +26,28 @@ void main() {
});
test(
'should update existing label when an updated label with the same ID is provided',
'should replace existing label if the same ID appears in the created list (Overwrite/Sync Fix)',
() {
final oldLabel = Label(id: Id('1'), displayName: 'Old Name');
final currentLabels = <Label>[oldLabel];
final currentLabels = [createLabel('1', 'Old Version')];
final newVersionLabel = createLabel('1', 'New Version');
final updatedLabel = Label(id: Id('1'), displayName: 'New Name');
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [newVersionLabel],
updated: [],
destroyedIds: [],
);
expect(currentLabels.length, 1,
reason: 'List should not contain duplicates');
expect(currentLabels.first.displayName, 'New Version');
});
});
group('Update Operations', () {
test('should update existing label when provided in updated list', () {
final currentLabels = [createLabel('1', 'Old Name')];
final updatedLabel = createLabel('1', 'New Name');
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
@@ -39,83 +60,11 @@ void main() {
expect(currentLabels.first.displayName, 'New Name');
});
test('should remove labels when their ID is present in destroyedIds', () {
final currentLabels = <Label>[
Label(id: Id('1'), displayName: 'To Delete'),
Label(id: Id('2'), displayName: 'Keep Me'),
];
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [],
updated: [],
destroyedIds: [Id('1')],
);
expect(currentLabels.length, 1);
expect(currentLabels.any((l) => l.id?.value == '1'), isFalse);
expect(currentLabels.any((l) => l.id?.value == '2'), isTrue);
});
test('should handle create, update, and destroy operations simultaneously',
() {
final currentLabels = <Label>[
Label(id: Id('1'), displayName: 'Old'),
Label(id: Id('2'), displayName: 'Remove'),
];
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [Label(id: Id('3'), displayName: 'New')],
updated: [Label(id: Id('1'), displayName: 'Updated')],
destroyedIds: [Id('2')],
);
expect(currentLabels.length, 2);
expect(currentLabels.any((l) => l.displayName == 'Updated'), isTrue);
expect(currentLabels.any((l) => l.displayName == 'New'), isTrue);
expect(currentLabels.any((l) => l.id?.value == '2'), isFalse);
});
test('should do nothing when all change lists are empty', () {
final currentLabels = <Label>[
Label(id: Id('1'), displayName: 'Existing'),
];
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [],
updated: [],
destroyedIds: [],
);
expect(currentLabels.length, 1);
expect(currentLabels.first.displayName, 'Existing');
});
test(
'should handle destroying IDs that do not exist in currentLabels gracefully',
() {
final currentLabels = <Label>[
Label(id: Id('1'), displayName: 'Keep Me'),
];
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [],
updated: [],
destroyedIds: [Id('999')],
);
expect(currentLabels.length, 1);
expect(currentLabels.first.id?.value, '1');
});
test(
'should treat updated labels as new additions if they do not exist in currentLabels (Upsert behavior)',
'should treat updated labels as new additions if they do not exist (Upsert behavior)',
() {
final currentLabels = <Label>[];
final labelToUpdate = Label(id: Id('1'), displayName: 'Ghost Label');
final labelToUpdate = createLabel('1', 'Ghost Label');
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
@@ -129,13 +78,13 @@ void main() {
});
test('should move updated labels to the end of the list', () {
final currentLabels = <Label>[
Label(id: Id('1'), displayName: 'A'),
Label(id: Id('2'), displayName: 'B'),
Label(id: Id('3'), displayName: 'C'),
final currentLabels = [
createLabel('1', 'A'),
createLabel('2', 'B'),
createLabel('3', 'C'),
];
final updatedLabelA = Label(id: Id('1'), displayName: 'A Updated');
final updatedLabelA = createLabel('1', 'A Updated');
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
@@ -150,14 +99,93 @@ void main() {
expect(currentLabels[2].id?.value, '1');
expect(currentLabels[2].displayName, 'A Updated');
});
});
test('should prioritize update over destroy if an ID appears in both lists',
() {
final currentLabels = <Label>[
Label(id: Id('1'), displayName: 'Original'),
group('Destroy Operations', () {
test('should remove labels when their ID is present in destroyedIds', () {
final currentLabels = [
createLabel('1', 'To Delete'),
createLabel('2', 'Keep Me'),
];
final updatedLabel = Label(id: Id('1'), displayName: 'Revived');
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [],
updated: [],
destroyedIds: [Id('1')],
);
expect(currentLabels.length, 1);
expect(currentLabels.any((l) => l.id?.value == '1'), isFalse);
expect(currentLabels.first.id?.value, '2');
});
test('should handle destroying IDs that do not exist gracefully', () {
final currentLabels = [createLabel('1', 'Keep Me')];
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [],
updated: [],
destroyedIds: [Id('999')],
);
expect(currentLabels.length, 1);
expect(currentLabels.first.id?.value, '1');
});
});
group('Complex Scenarios & Edge Cases', () {
test(
'should handle create, update, and destroy operations simultaneously',
() {
final currentLabels = [
createLabel('1', 'To Update'),
createLabel('2', 'To Destroy'),
createLabel('3', 'To Replace via Create'),
];
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [
createLabel('4', 'New 4'),
createLabel('3', 'Replaced 3'),
],
updated: [
createLabel('1', 'Updated 1'),
],
destroyedIds: [Id('2')],
);
expect(currentLabels.length, 3);
expect(currentLabels.any((l) => l.id?.value == '2'), isFalse);
expect(currentLabels.any((l) => l.displayName == 'Updated 1'), isTrue);
expect(currentLabels.any((l) => l.displayName == 'New 4'), isTrue);
expect(currentLabels.any((l) => l.displayName == 'Replaced 3'), isTrue);
expect(
currentLabels.any((l) => l.displayName == 'To Replace via Create'),
isFalse);
});
test('should do nothing when all change lists are empty', () {
final currentLabels = [createLabel('1', 'Existing')];
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
created: [],
updated: [],
destroyedIds: [],
);
expect(currentLabels.length, 1);
expect(currentLabels.first.displayName, 'Existing');
});
test(
'should prioritize update over destroy if an ID appears in both lists',
() {
final currentLabels = [createLabel('1', 'Original')];
final updatedLabel = createLabel('1', 'Revived');
LabelUtils.applyLabelChanges(
currentLabels: currentLabels,
@@ -170,4 +198,5 @@ void main() {
expect(currentLabels.first.displayName, 'Revived');
});
});
});
}