Files
linkding-ios/Marks/MarksApp.swift
T
Krishna KumarandClaude Opus 5 fffb3999bf
CI / build-and-deploy (pull_request) Successful in 34s
CI / build-and-deploy (push) Successful in 34s
Carry the library design across the rest of the app
Every screen now speaks the paper vocabulary rather than only the
bookmarks screen: Search, Sources, Podcasts and the player, Settings,
Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the
Siri snippets, and the share extension's save card.

The change is mostly a design system rather than per-screen tweaks.
LibraryKit gains:

- Semantic colors — secondary/tertiary/faint text, `raised` surfaces,
  and accent/alarm/affirm, so screens stop reaching for .secondary,
  systemGray, .blue, .red and .green. The three status colors come out
  of the palette (the swatch blue, vermilion and forest) rather than
  from the system set, so the design keeps spending one set of inks.
- PaperType — the two voices made explicit. Prose (titles, summaries,
  anything a person wrote) is serif; anything the machine contributes
  (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping
  that split strict is what makes the design read as archival rather
  than as decoration.
- paperSurface() / paperField() / paperCard() for grounds, inputs and
  raised blocks.
- PaperEmptyState, because ContentUnavailableView can't be restyled — it
  draws its own bold system type and grey, which was the loudest
  remaining system voice once the screens moved onto the sheet. All nine
  usages are converted.

The app also sets one accent for the system chrome it doesn't draw —
tab bar, search fields, switches, swipe actions — which otherwise stayed
system blue around paper screens.

BookmarkRow is deleted. Once Search moved to the library row and
TagBookmarksView was gone, nothing passed .classic, so the style fork in
BookmarkListRow went with it. There is one bookmark row now.

The share extension compiles LibraryKit directly, since its save card is
a user-facing surface and should not be the one place still using system
styling.

Verified on device in both color schemes; screens toured with a
temporary UI test harness (removed). Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 12:50:45 -05:00

160 lines
6.0 KiB
Swift

import SwiftUI
private let defaultConfig = ServerConfig(
host: "linkding-production-f7e0.up.railway.app",
port: nil,
path: "",
token: "04c3388f543a6f4401ae41958b4a459a0125c4bf",
useHttps: true
)
@main
struct MarksApp: App {
@State private var serverConfig: ServerConfig = ServerConfig.load() ?? defaultConfig
var body: some Scene {
WindowGroup {
MainContainer(config: serverConfig) {
defaultConfig.save()
serverConfig = defaultConfig
}
.task { serverConfig.save() }
}
}
}
private struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
// Holds the API + ViewModel so they survive re-renders
struct MainContainer: View {
let config: ServerConfig
let onDisconnect: () -> Void
@State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL?
@State private var showDeepLinkPlayer = false
@State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared
@State private var library = PodcastLibrary.shared
@State private var sourceLibrary = IngestedSourceLibrary()
@Environment(\.scenePhase) private var scenePhase
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config
self.onDisconnect = onDisconnect
let api = LinkdingAPI(config: config)
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host))
}
var body: some View {
TabView(selection: $selectedTab) {
Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
}
Tab("Sources", systemImage: "tray.full", value: AppTab.sources) {
SourcesView(
library: sourceLibrary,
podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator,
claude: viewModel.claude
)
}
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
}
.badge(library.unplayedCount)
Tab(value: AppTab.search, role: .search) {
SearchView(viewModel: viewModel)
}
}
// One accent for every system control the app doesn't draw itself —
// tab bar selection, search fields, switches, swipe actions. Without
// this the paper screens sit inside system-blue chrome.
.tint(Paper.accent)
.onOpenURL { url in
handleDeepLink(url)
}
.task {
applyPendingIntent()
viewModel.processPendingPodcastRequests()
viewModel.podcastPlayer.restoreSession(claude: viewModel.claude)
}
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
.onChange(of: scenePhase) { old, new in
// Refresh when returning to the foreground (cold launch is already
// covered by BookmarksView's .task, so only react to a real re-entry).
if new == .active && old != .active {
viewModel.processPendingPodcastRequests()
Task { await viewModel.load() }
}
// Persist playback position + session when leaving the foreground so a
// relaunch can resume where you left off.
if new == .background {
viewModel.podcastPlayer.saveProgress()
}
}
.sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
}
.sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
}
/// Reacts to requests an App Intent placed on `IntentRouter` (opening a
/// bookmark, or searching). Also runs once at launch so cold-starts via an
/// intent are honored.
private func applyPendingIntent() {
if let urlString = router.openBookmarkURL {
router.openBookmarkURL = nil
if let url = URL(string: urlString) {
deepLinkBrowser = IdentifiableURL(url: url)
}
}
if let query = router.searchRequest {
selectedTab = .search
Task {
viewModel.searchQuery = query
await viewModel.search()
}
// SearchView consumes the text from the router on appear; leave it
// set until then, then SearchView clears it.
}
}
private func handleDeepLink(_ url: URL) {
guard url.scheme == "marks",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
else { return }
switch url.host {
case "bookmark":
if let browserURL = URL(string: articleUrl) {
deepLinkBrowser = IdentifiableURL(url: browserURL)
}
case "podcast":
let podcasts = WidgetDataStore.loadPodcasts()
let found = podcasts.first { $0.articleUrl == articleUrl }
viewModel.podcastPlayer.start(
articleUrl: articleUrl,
articleTitle: found?.title ?? "",
claude: viewModel.claude
)
showDeepLinkPlayer = true
default:
break
}
}
}