Replace Flutter app with native SwiftUI rewrite (iOS 26)
Complete ground-up rewrite in SwiftUI targeting iOS 26. Drops the Flutter/Dart codebase entirely in favour of a lean native app with no third-party dependencies. Features shipped: - Bookmark list with pagination, pull-to-refresh, swipe delete/archive - Add bookmark form + iOS share extension (zero-tap save from any app) - Tags tab — all tags sorted by count, tap to browse filtered bookmarks - Native search tab (Tab role: .search) with instant client-side filtering - AI enrichment via OpenRouter (google/gemini-2.0-flash-lite-001): auto-summary and tag generation, semantic search, smart collections - Settings: linkding server config, OpenRouter API key - App Groups for credential sharing between main app and share extension - Swift 6 strict concurrency throughout (@Observable, @MainActor) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
144
Marks/Views/OnboardingView.swift
Normal file
144
Marks/Views/OnboardingView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
let onConnect: (ServerConfig) -> Void
|
||||
|
||||
@State private var urlText = ""
|
||||
@State private var token = ""
|
||||
@State private var isConnecting = false
|
||||
@State private var errorMessage: String?
|
||||
@FocusState private var focused: Field?
|
||||
|
||||
enum Field { case url, token }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Marks")
|
||||
.font(.system(size: 42, weight: .semibold, design: .rounded))
|
||||
Text("Your bookmarks, beautifully.")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 48)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Server URL")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
TextField("https://links.example.com", text: $urlText)
|
||||
.textContentType(.URL)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focused, equals: .url)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focused = .token }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("API Token")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
SecureField("Paste your token", text: $token)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focused, equals: .token)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { Task { await connect() } }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
Text("Find your token at Settings → API Token in Linkding.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if let err = errorMessage {
|
||||
Text(err)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.red)
|
||||
.padding(.top, 12)
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await connect() }
|
||||
} label: {
|
||||
Group {
|
||||
if isConnecting {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Connect")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(canConnect ? Color.primary : Color(.systemGray4))
|
||||
.foregroundStyle(canConnect ? Color(UIColor.systemBackground) : Color(.systemGray2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(!canConnect || isConnecting)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var canConnect: Bool { !urlText.isEmpty && !token.isEmpty }
|
||||
|
||||
private func connect() async {
|
||||
focused = nil
|
||||
isConnecting = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let config = try parseConfig()
|
||||
let api = LinkdingAPI(config: config)
|
||||
try await api.verifyConnection()
|
||||
onConnect(config)
|
||||
} catch APIError.badStatus(401) {
|
||||
errorMessage = "Invalid token. Check your API token in Linkding settings."
|
||||
} catch APIError.badStatus(let code) {
|
||||
errorMessage = "Server returned \(code). Check the URL."
|
||||
} catch {
|
||||
errorMessage = "Could not connect. Check the URL and try again."
|
||||
}
|
||||
|
||||
isConnecting = false
|
||||
}
|
||||
|
||||
private func parseConfig() throws -> ServerConfig {
|
||||
var raw = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !raw.hasPrefix("http://") && !raw.hasPrefix("https://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
guard let comps = URLComponents(string: raw),
|
||||
let host = comps.host, !host.isEmpty else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
return ServerConfig(
|
||||
host: host,
|
||||
port: comps.port,
|
||||
path: comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path,
|
||||
token: token.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
useHttps: comps.scheme == "https"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user