feat: Improve UI components and performance optimizations
- Refactor BookmarksView with better error handling and loading states - Optimize BookmarkLabelsViewModel with cached properties and reduced recomputation - Fix Core Data thread safety in LabelsRepository with performAndWait - Enhance TagManagementView with sorted selected labels display - Clean up ShareBookmarkViewModel comments - Update localization strings for error states - Bump build version to 19 These changes improve overall app performance and user experience across bookmark management workflows.
This commit is contained in:
parent
692f34d2ce
commit
76bc28ae02
@ -48,9 +48,6 @@
|
||||
},
|
||||
"%lld min" : {
|
||||
|
||||
},
|
||||
"%lld minutes" : {
|
||||
|
||||
},
|
||||
"%lld." : {
|
||||
|
||||
@ -102,12 +99,6 @@
|
||||
},
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
||||
|
||||
},
|
||||
"Automatic sync" : {
|
||||
|
||||
},
|
||||
"Automatically mark articles as read" : {
|
||||
|
||||
},
|
||||
"Available tags" : {
|
||||
|
||||
@ -120,9 +111,6 @@
|
||||
},
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
|
||||
|
||||
},
|
||||
"Clear cache" : {
|
||||
|
||||
},
|
||||
"Close" : {
|
||||
|
||||
@ -132,9 +120,6 @@
|
||||
},
|
||||
"Critical" : {
|
||||
|
||||
},
|
||||
"Data Management" : {
|
||||
|
||||
},
|
||||
"Debug" : {
|
||||
|
||||
@ -273,9 +258,6 @@
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
},
|
||||
"Open external links in in-app Safari" : {
|
||||
|
||||
},
|
||||
"Optional: Custom title" : {
|
||||
|
||||
@ -319,18 +301,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reading Settings" : {
|
||||
|
||||
},
|
||||
"Remove" : {
|
||||
|
||||
},
|
||||
"Reset" : {
|
||||
|
||||
},
|
||||
"Reset settings" : {
|
||||
|
||||
},
|
||||
"Reset to Defaults" : {
|
||||
|
||||
@ -340,9 +316,6 @@
|
||||
},
|
||||
"Resume listening" : {
|
||||
|
||||
},
|
||||
"Safari Reader Mode" : {
|
||||
|
||||
},
|
||||
"Save bookmark" : {
|
||||
|
||||
@ -391,12 +364,6 @@
|
||||
},
|
||||
"Speed" : {
|
||||
|
||||
},
|
||||
"Sync interval" : {
|
||||
|
||||
},
|
||||
"Sync Settings" : {
|
||||
|
||||
},
|
||||
"Syncing with server..." : {
|
||||
|
||||
@ -406,6 +373,12 @@
|
||||
},
|
||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." : {
|
||||
|
||||
},
|
||||
"Try Again" : {
|
||||
|
||||
},
|
||||
"Unable to load bookmarks" : {
|
||||
|
||||
},
|
||||
"Unarchive Bookmark" : {
|
||||
|
||||
|
||||
@ -103,12 +103,10 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
||||
logger.debug("Starting to load labels")
|
||||
Task {
|
||||
// Check if server is reachable
|
||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
||||
logger.debug("Server reachable for labels: \(serverReachable)")
|
||||
|
||||
if serverReachable {
|
||||
// Load from API
|
||||
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||
} ?? []
|
||||
@ -119,7 +117,6 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
measurement.end()
|
||||
}
|
||||
} else {
|
||||
// Load from local database
|
||||
let localTags = OfflineBookmarkManager.shared.getTags()
|
||||
let localLabels = localTags.enumerated().map { index, tagName in
|
||||
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
|
||||
|
||||
@ -436,7 +436,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -469,7 +469,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -624,7 +624,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -668,7 +668,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -29,6 +29,15 @@ class LabelsRepository: PLabelsRepository {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
|
||||
|
||||
return (try? coreDataManager.context.fetch(fetchRequest).isEmpty == false) ?? false
|
||||
var exists = false
|
||||
coreDataManager.context.performAndWait {
|
||||
do {
|
||||
let results = try coreDataManager.context.fetch(fetchRequest)
|
||||
exists = !results.isEmpty
|
||||
} catch {
|
||||
exists = false
|
||||
}
|
||||
}
|
||||
return exists
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,36 +12,40 @@ class BookmarkLabelsViewModel {
|
||||
var showErrorAlert = false
|
||||
var currentLabels: [String] = [] {
|
||||
didSet {
|
||||
if oldValue != currentLabels {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
var newLabelText = ""
|
||||
var searchText = "" {
|
||||
didSet {
|
||||
if oldValue != searchText {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var allLabels: [BookmarkLabel] = [] {
|
||||
didSet {
|
||||
if oldValue != allLabels {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var labelPages: [[BookmarkLabel]] = []
|
||||
|
||||
// Computed property for available labels (excluding current labels)
|
||||
// Cached properties to avoid recomputation
|
||||
private var _availableLabels: [BookmarkLabel] = []
|
||||
private var _filteredLabels: [BookmarkLabel] = []
|
||||
|
||||
var availableLabels: [BookmarkLabel] {
|
||||
return allLabels.filter { currentLabels.contains($0.name) == false }
|
||||
return _availableLabels
|
||||
}
|
||||
|
||||
// Computed property for filtered labels based on search text
|
||||
var filteredLabels: [BookmarkLabel] {
|
||||
if searchText.isEmpty {
|
||||
return availableLabels
|
||||
} else {
|
||||
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
return _filteredLabels
|
||||
}
|
||||
|
||||
var availableLabelPages: [[BookmarkLabel]] = []
|
||||
@ -76,8 +80,8 @@ class BookmarkLabelsViewModel {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
currentLabels.append(contentsOf: labels)
|
||||
currentLabels = Array(Set(currentLabels)) // Remove duplicates
|
||||
let uniqueLabels = Set(currentLabels + labels)
|
||||
currentLabels = currentLabels.filter { uniqueLabels.contains($0) } + labels.filter { !currentLabels.contains($0) }
|
||||
|
||||
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
|
||||
} catch let error as BookmarkUpdateError {
|
||||
@ -89,7 +93,6 @@ class BookmarkLabelsViewModel {
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -120,7 +123,6 @@ class BookmarkLabelsViewModel {
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -136,8 +138,6 @@ class BookmarkLabelsViewModel {
|
||||
} else {
|
||||
await addLabel(to: bookmarkId, label: label)
|
||||
}
|
||||
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
func updateLabels(_ labels: [String]) {
|
||||
@ -147,24 +147,31 @@ class BookmarkLabelsViewModel {
|
||||
private func calculatePages() {
|
||||
let pageSize = Constants.Labels.pageSize
|
||||
|
||||
// Update cached available labels
|
||||
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
|
||||
|
||||
// Update cached filtered labels
|
||||
if searchText.isEmpty {
|
||||
_filteredLabels = _availableLabels
|
||||
} else {
|
||||
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
// Calculate pages for all labels
|
||||
if allLabels.count <= pageSize {
|
||||
labelPages = [allLabels]
|
||||
} else {
|
||||
// Normal pagination for larger datasets
|
||||
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
|
||||
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate pages for filtered labels (search results or available labels)
|
||||
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
|
||||
if labelsToShow.count <= pageSize {
|
||||
availableLabelPages = [labelsToShow]
|
||||
// Calculate pages for filtered labels
|
||||
if _filteredLabels.count <= pageSize {
|
||||
availableLabelPages = [_filteredLabels]
|
||||
} else {
|
||||
// Normal pagination for larger datasets
|
||||
availableLabelPages = stride(from: 0, to: labelsToShow.count, by: pageSize).map {
|
||||
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
|
||||
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
|
||||
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,124 +39,15 @@ struct BookmarksView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.3)
|
||||
.tint(.accentColor)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Loading \(state.displayName)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Please wait while we fetch your bookmarks...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
if shouldShowCenteredState {
|
||||
centeredStateView
|
||||
} else {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||||
Button(action: {
|
||||
if UIDevice.isPhone {
|
||||
selectedBookmarkId = bookmark.id
|
||||
} else {
|
||||
if selectedBookmark?.id == bookmark.id {
|
||||
selectedBookmark = nil
|
||||
DispatchQueue.main.async {
|
||||
selectedBookmark = bookmark
|
||||
}
|
||||
} else {
|
||||
selectedBookmark = bookmark
|
||||
}
|
||||
}
|
||||
}) {
|
||||
BookmarkCardView(
|
||||
bookmark: bookmark,
|
||||
currentState: state,
|
||||
onArchive: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleArchive(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
onDelete: { bookmark in
|
||||
bookmarkToDelete = bookmark
|
||||
},
|
||||
onToggleFavorite: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleFavorite(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
namespace: namespace
|
||||
)
|
||||
.onAppear {
|
||||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||||
Task {
|
||||
await viewModel.loadMoreBookmarks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
.matchedTransitionSource(id: bookmark.id, in: namespace)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.scrollContentBackground(.hidden)
|
||||
.refreshable {
|
||||
await viewModel.refreshBookmarks()
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading {
|
||||
ContentUnavailableView(
|
||||
"No bookmarks",
|
||||
systemImage: "bookmark",
|
||||
description: Text(
|
||||
"No bookmarks found in \(state.displayName.lowercased())."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
bookmarksList
|
||||
}
|
||||
|
||||
// FAB Button - only show for "Unread"
|
||||
if state == .unread || state == .all {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showingAddBookmark = true
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accentColor)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
// FAB Button - only show for "Unread" and when not in error/loading state
|
||||
if (state == .unread || state == .all) && !shouldShowCenteredState {
|
||||
fabButton
|
||||
}
|
||||
}
|
||||
.navigationDestination(
|
||||
@ -206,6 +97,189 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var shouldShowCenteredState: Bool {
|
||||
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
|
||||
return isEmpty && (viewModel.isLoading || viewModel.errorMessage != nil)
|
||||
}
|
||||
|
||||
// MARK: - View Components
|
||||
|
||||
@ViewBuilder
|
||||
private var centeredStateView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
if viewModel.isLoading {
|
||||
loadingView
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
errorView(message: errorMessage)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.3)
|
||||
.tint(.accentColor)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Loading \(state.displayName)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Please wait while we fetch your bookmarks...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func errorView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.orange)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Unable to load bookmarks")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button("Try Again") {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bookmarksList: some View {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||||
Button(action: {
|
||||
if UIDevice.isPhone {
|
||||
selectedBookmarkId = bookmark.id
|
||||
} else {
|
||||
if selectedBookmark?.id == bookmark.id {
|
||||
selectedBookmark = nil
|
||||
DispatchQueue.main.async {
|
||||
selectedBookmark = bookmark
|
||||
}
|
||||
} else {
|
||||
selectedBookmark = bookmark
|
||||
}
|
||||
}
|
||||
}) {
|
||||
BookmarkCardView(
|
||||
bookmark: bookmark,
|
||||
currentState: state,
|
||||
onArchive: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleArchive(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
onDelete: { bookmark in
|
||||
bookmarkToDelete = bookmark
|
||||
},
|
||||
onToggleFavorite: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleFavorite(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
namespace: namespace
|
||||
)
|
||||
.onAppear {
|
||||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||||
Task {
|
||||
await viewModel.loadMoreBookmarks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
.matchedTransitionSource(id: bookmark.id, in: namespace)
|
||||
}
|
||||
|
||||
// Show loading indicator for pagination
|
||||
if viewModel.isLoading && !(viewModel.bookmarks?.bookmarks.isEmpty == true) {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.scrollContentBackground(.hidden)
|
||||
.refreshable {
|
||||
await viewModel.refreshBookmarks()
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading && viewModel.errorMessage == nil {
|
||||
ContentUnavailableView(
|
||||
"No bookmarks",
|
||||
systemImage: "bookmark",
|
||||
description: Text(
|
||||
"No bookmarks found in \(state.displayName.lowercased())."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fabButton: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showingAddBookmark = true
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accentColor)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@ -191,7 +191,7 @@ struct TagManagementView: View {
|
||||
.fontWeight(.medium)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(selectedLabelsSet), id: \.self) { label in
|
||||
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
||||
UnifiedLabelChip(
|
||||
label: label,
|
||||
isSelected: false,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user