feat: enhance UI with improved label management and splash screen

- Add new logo and splash screen assets with multiple resolutions
- Implement paginated label selection with TabView
- Create UnifiedLabelChip component for consistent label display
- Add manual tag entry functionality with validation
- Refactor BookmarkLabelsViewModel with dependency injection
- Update launch screen configuration and color sets
- Add new localization strings for improved UX
- Improve ShareBookmarkView with better label selection UI
This commit is contained in:
Ilyas Hallak 2025-07-29 21:26:32 +02:00
parent edf1234b53
commit 1cb87a4fb7
24 changed files with 564 additions and 177 deletions

View File

@ -25,6 +25,9 @@
},
"%lld min" : {
},
"%lld minutes" : {
},
"%lld." : {
@ -44,11 +47,14 @@
},
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." : {
},
"Add" : {
},
"Add a new link to your collection" : {
},
"Add new label" : {
"Add new tag..." : {
},
"all" : {
@ -61,6 +67,9 @@
}
}
}
},
"All available tags" : {
},
"Archive" : {
@ -73,9 +82,18 @@
},
"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" : {
},
"Cancel" : {
},
"Clear cache" : {
},
"Clipboard" : {
@ -85,6 +103,9 @@
},
"Current labels" : {
},
"Data Management" : {
},
"Delete" : {
@ -194,10 +215,10 @@
"No bookmarks found in %@." : {
},
"No labels available" : {
"OK" : {
},
"OK" : {
"Open external links in in-app Safari" : {
},
"Optional: Custom title" : {
@ -239,18 +260,27 @@
}
}
}
},
"Reading Settings" : {
},
"Remove" : {
},
"Required" : {
},
"Reset settings" : {
},
"Restore" : {
},
"Resume listening" : {
},
"Safari Reader Mode" : {
},
"Save bookmark" : {
@ -263,6 +293,9 @@
},
"Select a bookmark or tag" : {
},
"Select labels" : {
},
"Server Endpoint" : {
@ -284,6 +317,12 @@
},
"Suche..." : {
},
"Sync interval" : {
},
"Sync Settings" : {
},
"Theme" : {

View File

@ -49,11 +49,53 @@ struct ShareBookmarkView: View {
// Label Grid
if !viewModel.labels.isEmpty {
LabelGridView(labels: viewModel.labels, selected: $viewModel.selectedLabels)
VStack(alignment: .leading, spacing: 8) {
Text("Select labels")
.font(.headline)
.padding(.horizontal)
let pageSize = Constants.Labels.pageSize
let pages = stride(from: 0, to: viewModel.labels.count, by: pageSize).map {
Array(viewModel.labels[$0..<min($0 + pageSize, viewModel.labels.count)])
}
TabView {
ForEach(Array(pages.enumerated()), id: \.offset) { pageIndex, labelsPage in
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(labelsPage, id: \.name) { label in
UnifiedLabelChip(
label: label.name,
isSelected: viewModel.selectedLabels.contains(label.name),
isRemovable: false,
onTap: {
if viewModel.selectedLabels.contains(label.name) {
viewModel.selectedLabels.remove(label.name)
} else {
viewModel.selectedLabels.insert(label.name)
}
}
)
}
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.horizontal)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
.frame(height: 180)
.padding(.top, -20)
}
.padding(.top, 32)
.padding(.horizontal, 16)
.frame(minHeight: 100)
.frame(minHeight: 100)
}
ManualTagEntryView(
labels: viewModel.labels,
selectedLabels: $viewModel.selectedLabels
)
.padding(.top, 12)
.padding(.horizontal, 16)
// Status
if let status = viewModel.statusMessage {
Text(status.emoji + " " + status.text)
@ -89,33 +131,45 @@ struct ShareBookmarkView: View {
}
}
struct LabelGridView: View {
struct ManualTagEntryView: View {
let labels: [BookmarkLabelDto]
@Binding var selected: Set<String>
private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
@Binding var selectedLabels: Set<String>
@State private var manualTag: String = ""
@State private var error: String? = nil
var body: some View {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(labels.prefix(15), id: \ .name) { label in
Button(action: {
if selected.contains(label.name) {
selected.remove(label.name)
} else {
selected.insert(label.name)
}
}) {
Text(label.name)
.font(.system(size: 14, weight: .medium))
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(selected.contains(label.name) ? Color.accentColor.opacity(0.2) : Color(.secondarySystemGroupedBackground))
.foregroundColor(.primary)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selected.contains(label.name) ? Color.accentColor : Color.clear, lineWidth: 1)
)
VStack(alignment: .leading, spacing: 4) {
HStack {
TextField("Add new tag...", text: $manualTag)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
Button(action: addTag) {
Text("Add")
.font(.system(size: 15, weight: .semibold))
}
.disabled(manualTag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if let error = error {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
}
}
private func addTag() {
let trimmed = manualTag.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let lowercased = trimmed.lowercased()
let allExisting = Set(labels.map { $0.name.lowercased() })
let allSelected = Set(selectedLabels.map { $0.lowercased() })
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
error = "Tag already exists."
} else {
selectedLabels.insert(trimmed)
manualTag = ""
error = nil
}
}
}

View File

@ -52,7 +52,7 @@ class ShareBookmarkViewModel: ObservableObject {
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
self?.statusMessage = (message, error, error ? "" : "")
} ?? []
let sorted = loaded.prefix(10).sorted { $0.count > $1.count }
let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run {
self.labels = Array(sorted)
}

View File

@ -86,6 +86,9 @@
Data/KeychainHelper.swift,
Domain/Model/Bookmark.swift,
readeck.xcdatamodeld,
Splash.storyboard,
UI/Components/Constants.swift,
UI/Components/UnifiedLabelChip.swift,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};
@ -447,6 +450,10 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -476,6 +483,10 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View File

@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5A",
"green" : "0x4A",
"red" : "0x1F"
"blue" : "0x4B",
"green" : "0x41",
"red" : "0x20"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,59 @@
{
"images" : [
{
"filename" : "logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "logo 5.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "logo 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "logo 4.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "logo 2.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "logo 3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "readeck.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "readeck 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "readeck 2.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x60",
"green" : "0x4E",
"red" : "0x01"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -5,8 +5,8 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5A",
"green" : "0x4A",
"blue" : "0x5B",
"green" : "0x4B",
"red" : "0x1F"
}
},
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5A",
"green" : "0x4A",
"red" : "0x1F"
"blue" : "0x4C",
"green" : "0x40",
"red" : "0x21"
}
},
"idiom" : "universal"

View File

@ -25,9 +25,9 @@
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>splashBg</string>
<string>splashBackground</string>
<key>UIImageName</key>
<string>readeck</string>
<string>splash</string>
</dict>
</dict>
</plist>

67
readeck/Splash.storyboard Normal file
View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<device id="retina6_12" orientation="portrait" appearance="dark"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" clearsContextBeforeDrawing="NO" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="NbE-6K-ltk">
<rect key="frame" x="130" y="368" width="133.33333333333337" height="116"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="133.33000000000001" id="MuN-6D-myL"/>
<constraint firstAttribute="height" constant="115.67" id="ebY-kI-orh"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TRS-0D-Iyx">
<rect key="frame" x="175" y="669" width="42" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" clearsContextBeforeDrawing="NO" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="bt9-XM-VsM">
<rect key="frame" x="155" y="59" width="82" height="80"/>
<constraints>
<constraint firstAttribute="width" constant="82" id="mei-64-UsF"/>
<constraint firstAttribute="width" secondItem="bt9-XM-VsM" secondAttribute="height" multiplier="41:40" id="wHS-wO-Ehi"/>
</constraints>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" name="BrightWhite"/>
<constraints>
<constraint firstItem="vDu-zF-Fre" firstAttribute="trailing" secondItem="bt9-XM-VsM" secondAttribute="trailing" constant="156" id="Nqh-Mz-6ie"/>
<constraint firstItem="NbE-6K-ltk" firstAttribute="centerX" secondItem="5EZ-qb-Rvc" secondAttribute="centerX" id="S8g-oD-g1Z"/>
<constraint firstItem="bt9-XM-VsM" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" constant="59" id="X6D-aF-ddp"/>
<constraint firstItem="vDu-zF-Fre" firstAttribute="trailing" secondItem="TRS-0D-Iyx" secondAttribute="trailing" constant="176" id="Ybu-ZP-2KF"/>
<constraint firstItem="bt9-XM-VsM" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" constant="155" id="Zfp-jh-UjO"/>
<constraint firstItem="TRS-0D-Iyx" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" constant="175" id="aBg-bZ-l74"/>
<constraint firstItem="NbE-6K-ltk" firstAttribute="centerY" secondItem="5EZ-qb-Rvc" secondAttribute="centerY" id="f9e-9Z-hjf"/>
<constraint firstItem="vDu-zF-Fre" firstAttribute="bottom" secondItem="TRS-0D-Iyx" secondAttribute="bottom" constant="94" id="iFw-vd-HUs"/>
<constraint firstItem="TRS-0D-Iyx" firstAttribute="top" secondItem="NbE-6K-ltk" secondAttribute="bottom" constant="185" id="mc7-gW-Rsl"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="125.95419847328243" y="-17.605633802816904"/>
</scene>
</scenes>
<resources>
<image name="logo" width="133.33332824707031" height="115.66666412353516"/>
<namedColor name="BrightWhite">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -5,32 +5,111 @@ struct BookmarkLabelsView: View {
@State private var viewModel: BookmarkLabelsViewModel
@Environment(\.dismiss) private var dismiss
init(bookmarkId: String, initialLabels: [String]) {
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
self.bookmarkId = bookmarkId
self._viewModel = State(initialValue: BookmarkLabelsViewModel(initialLabels: initialLabels))
self._viewModel = State(initialValue: viewModel ?? BookmarkLabelsViewModel(initialLabels: initialLabels))
UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.primary)
UIPageControl.appearance().pageIndicatorTintColor = UIColor(Color.primary).withAlphaComponent(0.2)
}
var body: some View {
NavigationView {
VStack(spacing: 16) {
// Add new label section
addLabelSection
VStack(spacing: 12) {
// Add new label
HStack(spacing: 12) {
TextField("Enter label...", text: $viewModel.newLabelText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
}
}
Button(action: {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.frame(width: 32, height: 32)
}
.disabled(viewModel.newLabelText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading)
.foregroundColor(.accentColor)
}
.padding(.horizontal)
Divider()
.padding(.horizontal, -16)
// All available labels
if !viewModel.allLabels.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("All available tags")
.font(.headline)
.padding(.horizontal)
TabView {
ForEach(Array(viewModel.labelPages.enumerated()), id: \ .offset) { pageIndex, labelsPage in
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(labelsPage.filter { !viewModel.currentLabels.contains($0.name) }, id: \ .id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: viewModel.currentLabels.contains(label.name),
isRemovable: false,
onTap: {
Task {
await viewModel.toggleLabel(for: bookmarkId, label: label.name)
}
}
)
}
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.horizontal)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
.frame(height: 180)
.padding(.top, -20)
}
}
// Current labels section
currentLabelsSection
// Current labels
if !viewModel.currentLabels.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Current labels")
.font(.headline)
.padding(.horizontal)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(viewModel.currentLabels, id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: true,
isRemovable: true,
onTap: {
// No action for current labels
},
onRemove: {
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
}
}
)
}
}
.padding(.horizontal)
}
}
Spacer()
}
.padding()
.padding(.vertical)
.background(Color(.systemGroupedBackground))
.navigationTitle("Manage Labels")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
@ -46,131 +125,17 @@ struct BookmarkLabelsView: View {
} message: {
Text(viewModel.errorMessage ?? "Unknown error")
}
}
}
private var addLabelSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Add new label")
.font(.headline)
.foregroundColor(.primary)
HStack(spacing: 12) {
TextField("Enter label...", text: $viewModel.newLabelText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
}
}
Button(action: {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(
Circle()
.fill(Color.accentColor)
)
}
.disabled(viewModel.newLabelText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading)
.task {
await viewModel.loadAllLabels()
}
.ignoresSafeArea(.keyboard)
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
)
}
private var currentLabelsSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Current labels")
.font(.headline)
.foregroundColor(.primary)
Spacer()
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
}
}
if viewModel.currentLabels.isEmpty {
VStack(spacing: 8) {
Image(systemName: "tag")
.font(.title2)
.foregroundColor(.secondary)
Text("No labels available")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 80, maximum: 150))
], spacing: 4) {
ForEach(viewModel.currentLabels, id: \.self) { label in
LabelChip(
label: label,
onRemove: {
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
}
}
)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
)
}
}
struct LabelChip: View {
let label: String
let onRemove: () -> Void
var body: some View {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.accentColor.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.accentColor.opacity(0.4), lineWidth: 1)
)
)
}
}
#Preview {
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"])
}
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"], viewModel: .init(MockUseCaseFactory(), initialLabels: ["test"]))
}

View File

@ -2,8 +2,9 @@ import Foundation
@Observable
class BookmarkLabelsViewModel {
private let addLabelsUseCase = DefaultUseCaseFactory.shared.makeAddLabelsToBookmarkUseCase()
private let removeLabelsUseCase = DefaultUseCaseFactory.shared.makeRemoveLabelsFromBookmarkUseCase()
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
private let getLabelsUseCase: PGetLabelsUseCase
var isLoading = false
var errorMessage: String?
@ -11,8 +12,38 @@ class BookmarkLabelsViewModel {
var currentLabels: [String] = []
var newLabelText = ""
init(initialLabels: [String] = []) {
var allLabels: [BookmarkLabel] = [] {
didSet {
let pageSize = Constants.Labels.pageSize
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
}
}
var labelPages: [[BookmarkLabel]] = []
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels
self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase()
self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase()
self.getLabelsUseCase = factory.makeGetLabelsUseCase()
}
@MainActor
func loadAllLabels() async {
isLoading = true
defer { isLoading = false }
do {
let labels = try await getLabelsUseCase.execute()
allLabels = labels
} catch {
errorMessage = "failed to load labels"
showErrorAlert = true
}
}
@MainActor
@ -83,4 +114,4 @@ class BookmarkLabelsViewModel {
func updateLabels(_ labels: [String]) {
currentLabels = labels
}
}
}

View File

@ -0,0 +1,18 @@
//
// Constants.swift
// readeck
//
// Created by Ilyas Hallak on 21.07.25.
//
// SPDX-License-Identifier: MIT
//
// This file is part of the readeck project and is licensed under the MIT License.
//
import Foundation
struct Constants {
struct Labels {
static let pageSize = 12
}
}

View File

@ -0,0 +1,97 @@
//
// UnifiedLabelChip.swift
// readeck
//
// Created by Ilyas Hallak on 21.07.25.
//
// SPDX-License-Identifier: MIT
//
// This file is part of the readeck project and is licensed under the MIT License.
//
import SwiftUI
struct UnifiedLabelChip: View {
let label: String
let isSelected: Bool
let isRemovable: Bool
let onTap: () -> Void
let onRemove: (() -> Void)?
init(label: String, isSelected: Bool = false, isRemovable: Bool = false, onTap: @escaping () -> Void, onRemove: (() -> Void)? = nil) {
self.label = label
self.isSelected = isSelected
self.isRemovable = isRemovable
self.onTap = onTap
self.onRemove = onRemove
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(1)
.truncationMode(.tail)
if isRemovable, let onRemove = onRemove {
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundColor(isSelected ? .white.opacity(0.8) : .secondary)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(minHeight: 32)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isSelected ? Color.accentColor : Color.accentColor.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.accentColor.opacity(0.4), lineWidth: 1)
)
)
}
.buttonStyle(PlainButtonStyle())
}
}
#Preview {
VStack(spacing: 16) {
UnifiedLabelChip(
label: "Sample Label",
isSelected: false,
isRemovable: false,
onTap: {}
)
UnifiedLabelChip(
label: "Selected Label",
isSelected: true,
isRemovable: false,
onTap: {}
)
UnifiedLabelChip(
label: "Removable Label",
isSelected: false,
isRemovable: true,
onTap: {},
onRemove: {}
)
UnifiedLabelChip(
label: "Selected & Removable",
isSelected: true,
isRemovable: true,
onTap: {},
onRemove: {}
)
}
.padding()
}