mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 01:29:04 +03:00
@@ -1218,6 +1218,7 @@
|
|||||||
"filter_description": "Conditions to filter the target assets",
|
"filter_description": "Conditions to filter the target assets",
|
||||||
"filter_people": "Filter people",
|
"filter_people": "Filter people",
|
||||||
"filter_places": "Filter places",
|
"filter_places": "Filter places",
|
||||||
|
"filter_tags": "Filter tags",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"find_them_fast": "Find them fast by name with search",
|
"find_them_fast": "Find them fast by name with search",
|
||||||
"first": "First",
|
"first": "First",
|
||||||
@@ -1945,6 +1946,7 @@
|
|||||||
"search_filter_ocr": "Search by OCR",
|
"search_filter_ocr": "Search by OCR",
|
||||||
"search_filter_people_title": "Select people",
|
"search_filter_people_title": "Select people",
|
||||||
"search_filter_star_rating": "Star Rating",
|
"search_filter_star_rating": "Star Rating",
|
||||||
|
"search_filter_tags_title": "Select tags",
|
||||||
"search_for": "Search for",
|
"search_for": "Search for",
|
||||||
"search_for_existing_person": "Search for existing person",
|
"search_for_existing_person": "Search for existing person",
|
||||||
"search_no_more_result": "No more results",
|
"search_no_more_result": "No more results",
|
||||||
|
|||||||
29
mobile/lib/domain/models/tag.model.dart
Normal file
29
mobile/lib/domain/models/tag.model.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
class Tag {
|
||||||
|
final String id;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const Tag({required this.id, required this.value});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Tag(id: $id, value: $value)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant Tag other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.id == id && other.value == value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return id.hashCode ^ value.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Tag fromDto(TagResponseDto dto) {
|
||||||
|
return Tag(id: dto.id, value: dto.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ class SearchApiRepository extends ApiRepository {
|
|||||||
isFavorite: filter.display.isFavorite ? true : null,
|
isFavorite: filter.display.isFavorite ? true : null,
|
||||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
|
tagIds: filter.tagIds,
|
||||||
type: type,
|
type: type,
|
||||||
page: page,
|
page: page,
|
||||||
size: 100,
|
size: 100,
|
||||||
@@ -59,6 +60,7 @@ class SearchApiRepository extends ApiRepository {
|
|||||||
isFavorite: filter.display.isFavorite ? true : null,
|
isFavorite: filter.display.isFavorite ? true : null,
|
||||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
|
tagIds: filter.tagIds,
|
||||||
type: type,
|
type: type,
|
||||||
page: page,
|
page: page,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
final tagsApiRepositoryProvider = Provider<TagsApiRepository>(
|
||||||
|
(ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi),
|
||||||
|
);
|
||||||
|
|
||||||
|
class TagsApiRepository extends ApiRepository {
|
||||||
|
final TagsApi _api;
|
||||||
|
const TagsApiRepository(this._api);
|
||||||
|
|
||||||
|
Future<List<TagResponseDto>?> getAllTags() async {
|
||||||
|
return await _api.getAllTags();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,6 +214,7 @@ class SearchFilter {
|
|||||||
String? ocr;
|
String? ocr;
|
||||||
String? language;
|
String? language;
|
||||||
String? assetId;
|
String? assetId;
|
||||||
|
List<String>? tagIds;
|
||||||
Set<PersonDto> people;
|
Set<PersonDto> people;
|
||||||
SearchLocationFilter location;
|
SearchLocationFilter location;
|
||||||
SearchCameraFilter camera;
|
SearchCameraFilter camera;
|
||||||
@@ -231,6 +232,7 @@ class SearchFilter {
|
|||||||
this.ocr,
|
this.ocr,
|
||||||
this.language,
|
this.language,
|
||||||
this.assetId,
|
this.assetId,
|
||||||
|
this.tagIds,
|
||||||
required this.people,
|
required this.people,
|
||||||
required this.location,
|
required this.location,
|
||||||
required this.camera,
|
required this.camera,
|
||||||
@@ -246,6 +248,7 @@ class SearchFilter {
|
|||||||
(description == null || (description!.isEmpty)) &&
|
(description == null || (description!.isEmpty)) &&
|
||||||
(assetId == null || (assetId!.isEmpty)) &&
|
(assetId == null || (assetId!.isEmpty)) &&
|
||||||
(ocr == null || (ocr!.isEmpty)) &&
|
(ocr == null || (ocr!.isEmpty)) &&
|
||||||
|
(tagIds ?? []).isEmpty &&
|
||||||
people.isEmpty &&
|
people.isEmpty &&
|
||||||
location.country == null &&
|
location.country == null &&
|
||||||
location.state == null &&
|
location.state == null &&
|
||||||
@@ -269,6 +272,7 @@ class SearchFilter {
|
|||||||
String? ocr,
|
String? ocr,
|
||||||
String? assetId,
|
String? assetId,
|
||||||
Set<PersonDto>? people,
|
Set<PersonDto>? people,
|
||||||
|
List<String>? tagIds,
|
||||||
SearchLocationFilter? location,
|
SearchLocationFilter? location,
|
||||||
SearchCameraFilter? camera,
|
SearchCameraFilter? camera,
|
||||||
SearchDateFilter? date,
|
SearchDateFilter? date,
|
||||||
@@ -290,12 +294,13 @@ class SearchFilter {
|
|||||||
display: display ?? this.display,
|
display: display ?? this.display,
|
||||||
rating: rating ?? this.rating,
|
rating: rating ?? this.rating,
|
||||||
mediaType: mediaType ?? this.mediaType,
|
mediaType: mediaType ?? this.mediaType,
|
||||||
|
tagIds: tagIds ?? this.tagIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -309,6 +314,7 @@ class SearchFilter {
|
|||||||
other.ocr == ocr &&
|
other.ocr == ocr &&
|
||||||
other.assetId == assetId &&
|
other.assetId == assetId &&
|
||||||
other.people == people &&
|
other.people == people &&
|
||||||
|
other.tagIds == tagIds &&
|
||||||
other.location == location &&
|
other.location == location &&
|
||||||
other.camera == camera &&
|
other.camera == camera &&
|
||||||
other.date == date &&
|
other.date == date &&
|
||||||
@@ -326,6 +332,7 @@ class SearchFilter {
|
|||||||
ocr.hashCode ^
|
ocr.hashCode ^
|
||||||
assetId.hashCode ^
|
assetId.hashCode ^
|
||||||
people.hashCode ^
|
people.hashCode ^
|
||||||
|
tagIds.hashCode ^
|
||||||
location.hashCode ^
|
location.hashCode ^
|
||||||
camera.hashCode ^
|
camera.hashCode ^
|
||||||
date.hashCode ^
|
date.hashCode ^
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
@@ -24,6 +25,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
|
|||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/common/feature_check.dart';
|
import 'package:immich_mobile/widgets/common/feature_check.dart';
|
||||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||||
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
|
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
|
||||||
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
|
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
|
||||||
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
|
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
|
||||||
@@ -62,6 +64,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
mediaType: preFilter?.mediaType ?? AssetType.other,
|
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||||
assetId: preFilter?.assetId,
|
assetId: preFilter?.assetId,
|
||||||
|
tagIds: preFilter?.tagIds ?? [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,15 +75,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
final tagCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
|
||||||
final isSearching = useState(false);
|
final isSearching = useState(false);
|
||||||
|
|
||||||
final isRatingEnabled = ref
|
final userPreferences = ref.watch(userMetadataPreferencesProvider);
|
||||||
.watch(userMetadataPreferencesProvider)
|
|
||||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
|
||||||
|
|
||||||
SnackBar searchInfoSnackBar(String message) {
|
SnackBar searchInfoSnackBar(String message) {
|
||||||
return SnackBar(
|
return SnackBar(
|
||||||
@@ -177,6 +179,42 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showTagPicker() {
|
||||||
|
handleOnSelect(Iterable<Tag> tags) {
|
||||||
|
filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList());
|
||||||
|
final label = tags.map((t) => t.value).join(', ');
|
||||||
|
if (label.isEmpty) {
|
||||||
|
tagCurrentFilterWidget.value = null;
|
||||||
|
} else {
|
||||||
|
tagCurrentFilterWidget.value = Text(
|
||||||
|
label.isEmpty ? 'tags'.t(context: context) : label,
|
||||||
|
style: context.textTheme.labelLarge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
filter.value = filter.value.copyWith(tagIds: []);
|
||||||
|
tagCurrentFilterWidget.value = null;
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilterBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
heightFactor: 0.8,
|
||||||
|
child: FilterBottomSheetScaffold(
|
||||||
|
title: 'search_filter_tags_title'.t(context: context),
|
||||||
|
expanded: true,
|
||||||
|
onSearch: search,
|
||||||
|
onClear: handleClear,
|
||||||
|
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
showLocationPicker() {
|
showLocationPicker() {
|
||||||
handleOnSelect(Map<String, String?> value) {
|
handleOnSelect(Map<String, String?> value) {
|
||||||
filter.value = filter.value.copyWith(
|
filter.value = filter.value.copyWith(
|
||||||
@@ -658,6 +696,13 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
label: 'search_filter_location'.t(context: context),
|
label: 'search_filter_location'.t(context: context),
|
||||||
currentFilter: locationCurrentFilterWidget.value,
|
currentFilter: locationCurrentFilterWidget.value,
|
||||||
),
|
),
|
||||||
|
if (userPreferences.value?.tagsEnabled ?? false)
|
||||||
|
SearchFilterChip(
|
||||||
|
icon: Icons.sell_outlined,
|
||||||
|
onTap: showTagPicker,
|
||||||
|
label: 'tags'.t(context: context),
|
||||||
|
currentFilter: tagCurrentFilterWidget.value,
|
||||||
|
),
|
||||||
SearchFilterChip(
|
SearchFilterChip(
|
||||||
icon: Icons.camera_alt_outlined,
|
icon: Icons.camera_alt_outlined,
|
||||||
onTap: showCameraPicker,
|
onTap: showCameraPicker,
|
||||||
@@ -677,7 +722,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
label: 'search_filter_media_type'.t(context: context),
|
label: 'search_filter_media_type'.t(context: context),
|
||||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||||
),
|
),
|
||||||
if (isRatingEnabled) ...[
|
if (userPreferences.value?.ratingsEnabled ?? false) ...[
|
||||||
SearchFilterChip(
|
SearchFilterChip(
|
||||||
icon: Icons.star_outline_rounded,
|
icon: Icons.star_outline_rounded,
|
||||||
onTap: showStarRatingPicker,
|
onTap: showStarRatingPicker,
|
||||||
|
|||||||
17
mobile/lib/providers/infrastructure/tag.provider.dart
Normal file
17
mobile/lib/providers/infrastructure/tag.provider.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
|
||||||
|
|
||||||
|
class TagNotifier extends AsyncNotifier<Set<Tag>> {
|
||||||
|
@override
|
||||||
|
Future<Set<Tag>> build() async {
|
||||||
|
final repo = ref.read(tagsApiRepositoryProvider);
|
||||||
|
final allTags = await repo.getAllTags();
|
||||||
|
if (allTags == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return allTags.map((t) => Tag.fromDto(t)).toSet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final tagProvider = AsyncNotifierProvider<TagNotifier, Set<Tag>>(TagNotifier.new);
|
||||||
@@ -35,6 +35,7 @@ class ApiService implements Authentication {
|
|||||||
late ViewsApi viewApi;
|
late ViewsApi viewApi;
|
||||||
late MemoriesApi memoriesApi;
|
late MemoriesApi memoriesApi;
|
||||||
late SessionsApi sessionsApi;
|
late SessionsApi sessionsApi;
|
||||||
|
late TagsApi tagsApi;
|
||||||
|
|
||||||
ApiService() {
|
ApiService() {
|
||||||
// The below line ensures that the api clients are initialized when the service is instantiated
|
// The below line ensures that the api clients are initialized when the service is instantiated
|
||||||
@@ -74,6 +75,7 @@ class ApiService implements Authentication {
|
|||||||
viewApi = ViewsApi(_apiClient);
|
viewApi = ViewsApi(_apiClient);
|
||||||
memoriesApi = MemoriesApi(_apiClient);
|
memoriesApi = MemoriesApi(_apiClient);
|
||||||
sessionsApi = SessionsApi(_apiClient);
|
sessionsApi = SessionsApi(_apiClient);
|
||||||
|
tagsApi = TagsApi(_apiClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setUserAgentHeader() async {
|
Future<void> _setUserAgentHeader() async {
|
||||||
|
|||||||
89
mobile/lib/widgets/common/tag_picker.dart
Normal file
89
mobile/lib/widgets/common/tag_picker.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||||
|
|
||||||
|
class TagPicker extends HookConsumerWidget {
|
||||||
|
const TagPicker({super.key, required this.onSelect, required this.filter});
|
||||||
|
|
||||||
|
final Function(Iterable<Tag>) onSelect;
|
||||||
|
final Set<String> filter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final formFocus = useFocusNode();
|
||||||
|
final searchQuery = useState('');
|
||||||
|
final tags = ref.watch(tagProvider);
|
||||||
|
final selectedTagIds = useState<Set<String>>(filter);
|
||||||
|
final borderRadius = const BorderRadius.all(Radius.circular(10));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: SearchField(
|
||||||
|
focusNode: formFocus,
|
||||||
|
onChanged: (value) => searchQuery.value = value,
|
||||||
|
onTapOutside: (_) => formFocus.unfocus(),
|
||||||
|
filled: true,
|
||||||
|
hintText: 'filter_tags'.tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0),
|
||||||
|
child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: tags.widgetWhen(
|
||||||
|
onData: (tags) {
|
||||||
|
final queryResult = tags
|
||||||
|
.where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: queryResult.length,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final tag = queryResult[index];
|
||||||
|
final isSelected = selectedTagIds.value.any((id) => id == tag.id);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2.0),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25),
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(
|
||||||
|
tag.value,
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final newSelected = {...selectedTagIds.value};
|
||||||
|
if (isSelected) {
|
||||||
|
newSelected.removeWhere((id) => id == tag.id);
|
||||||
|
} else {
|
||||||
|
newSelected.add(tag.id);
|
||||||
|
}
|
||||||
|
selectedTagIds.value = newSelected;
|
||||||
|
onSelect(tags.where((t) => newSelected.contains(t.id)));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user