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
@ -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" : {
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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"
|
||||
|
||||
59
readeck/Assets.xcassets/logo.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
readeck/Assets.xcassets/logo.imageset/logo 1.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 2.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 3.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 4.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 5.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
26
readeck/Assets.xcassets/splash.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
readeck/Assets.xcassets/splash.imageset/readeck 1.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
readeck/Assets.xcassets/splash.imageset/readeck 2.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
readeck/Assets.xcassets/splash.imageset/readeck.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
@ -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>
|
||||
@ -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"]))
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
readeck/UI/Components/Constants.swift
Normal 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
|
||||
}
|
||||
}
|
||||
97
readeck/UI/Components/UnifiedLabelChip.swift
Normal 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()
|
||||
}
|
||||