diff --git a/.agents/skills/repo-conventions/SKILL.md b/.agents/skills/repo-conventions/SKILL.md index 0e3ef8199..192d624ab 100644 --- a/.agents/skills/repo-conventions/SKILL.md +++ b/.agents/skills/repo-conventions/SKILL.md @@ -169,6 +169,7 @@ Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide r - Trackers (`/api/v1/trackers/*`) are kmrs-private per-user integrations, not admin features and absent on Komga. `TrackerCapabilityStore` probes preferences with independent 24h negative capability records, clears preferences on account switches, and gates series actions by both support and per-user library preferences. Account and series management are online-only on iOS/macOS; tvOS has no tracker UI. - Native OAuth is intentionally not implemented: kmrs's current callback requires a browser session, while KMReader uses API keys. Disconnected providers link to `/account/trackers` in the server web UI, never to a relay carrying the KMReader credential. Connected account status and logout use the kmrs endpoints; provider tokens remain server-side. - `TrackerSeriesSheet` lists existing per-series bindings, adds a provider only when connected, and opens `TrackerLinkEditSheet` for search, binding mode/offset, and sparse remote updates. Empty remote fields are never submitted, binding-only saves do not call remote update, and an Unlink never edits the provider entry. Server-side automatic sync owns progress aggregation; the client does not mirror it. Grouped bindings and the manual-update ledger use `PaginationState`; the ledger is recent manual updates, not a full bound-series catalog. +- Supported, enabled series detail pages always expose the tracker flow below the metadata chips: branded binding chips show remote reading status, open their own `TrackerLinkEditSheet` on tap, and a persistent Add Tracker button opens `TrackerSeriesSheet`; the toolbar and card context-menu entries remain. The chips use `TrackerLogo`, the same provider artwork as kmweb, also shown in tracker account rows. No extra section title or enclosing card. ## Browse & Dashboard diff --git a/KMReader.xcodeproj/project.pbxproj b/KMReader.xcodeproj/project.pbxproj index c85e0fde0..66c591556 100644 --- a/KMReader.xcodeproj/project.pbxproj +++ b/KMReader.xcodeproj/project.pbxproj @@ -443,7 +443,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 667; + CURRENT_PROJECT_VERSION = 668; DEVELOPMENT_TEAM = M777UHWZA4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -502,7 +502,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 667; + CURRENT_PROJECT_VERSION = 668; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; @@ -563,7 +563,7 @@ CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 667; + CURRENT_PROJECT_VERSION = 668; DEVELOPMENT_TEAM = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KMReaderWidgets/Info.plist; @@ -597,7 +597,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 667; + CURRENT_PROJECT_VERSION = 668; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; diff --git a/KMReader/Assets.xcassets/TrackerAniList.imageset/Contents.json b/KMReader/Assets.xcassets/TrackerAniList.imageset/Contents.json new file mode 100644 index 000000000..8e7954109 --- /dev/null +++ b/KMReader/Assets.xcassets/TrackerAniList.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "anilist.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KMReader/Assets.xcassets/TrackerAniList.imageset/anilist.png b/KMReader/Assets.xcassets/TrackerAniList.imageset/anilist.png new file mode 100644 index 000000000..266af07cb Binary files /dev/null and b/KMReader/Assets.xcassets/TrackerAniList.imageset/anilist.png differ diff --git a/KMReader/Assets.xcassets/TrackerBangumi.imageset/Contents.json b/KMReader/Assets.xcassets/TrackerBangumi.imageset/Contents.json new file mode 100644 index 000000000..f10dc034e --- /dev/null +++ b/KMReader/Assets.xcassets/TrackerBangumi.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bangumi.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KMReader/Assets.xcassets/TrackerBangumi.imageset/bangumi.png b/KMReader/Assets.xcassets/TrackerBangumi.imageset/bangumi.png new file mode 100644 index 000000000..f94fb0e7e Binary files /dev/null and b/KMReader/Assets.xcassets/TrackerBangumi.imageset/bangumi.png differ diff --git a/KMReader/Assets.xcassets/TrackerMangaBaka.imageset/Contents.json b/KMReader/Assets.xcassets/TrackerMangaBaka.imageset/Contents.json new file mode 100644 index 000000000..78383203f --- /dev/null +++ b/KMReader/Assets.xcassets/TrackerMangaBaka.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mangabaka.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KMReader/Assets.xcassets/TrackerMangaBaka.imageset/mangabaka.png b/KMReader/Assets.xcassets/TrackerMangaBaka.imageset/mangabaka.png new file mode 100644 index 000000000..ab974bdc0 Binary files /dev/null and b/KMReader/Assets.xcassets/TrackerMangaBaka.imageset/mangabaka.png differ diff --git a/KMReader/Assets.xcassets/TrackerMyAnimeList.imageset/Contents.json b/KMReader/Assets.xcassets/TrackerMyAnimeList.imageset/Contents.json new file mode 100644 index 000000000..52995e74d --- /dev/null +++ b/KMReader/Assets.xcassets/TrackerMyAnimeList.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mal.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KMReader/Assets.xcassets/TrackerMyAnimeList.imageset/mal.png b/KMReader/Assets.xcassets/TrackerMyAnimeList.imageset/mal.png new file mode 100644 index 000000000..dd9ee66f9 Binary files /dev/null and b/KMReader/Assets.xcassets/TrackerMyAnimeList.imageset/mal.png differ diff --git a/KMReader/Features/Tracker/Models/TrackerProvider.swift b/KMReader/Features/Tracker/Models/TrackerProvider.swift index 276b2f4fb..32980c1c2 100644 --- a/KMReader/Features/Tracker/Models/TrackerProvider.swift +++ b/KMReader/Features/Tracker/Models/TrackerProvider.swift @@ -19,6 +19,15 @@ nonisolated enum TrackerProvider: String, CaseIterable, Codable, Sendable { } } + var imageName: String { + switch self { + case .anilist: return "TrackerAniList" + case .mal: return "TrackerMyAnimeList" + case .bangumi: return "TrackerBangumi" + case .mangabaka: return "TrackerMangaBaka" + } + } + func entryURL(trackId: String) -> URL? { switch self { case .anilist: return URL(string: "https://anilist.co/manga/\(trackId)") diff --git a/KMReader/Features/Tracker/Views/TrackerAccountRow.swift b/KMReader/Features/Tracker/Views/TrackerAccountRow.swift index 54290710c..25e6fb590 100644 --- a/KMReader/Features/Tracker/Views/TrackerAccountRow.swift +++ b/KMReader/Features/Tracker/Views/TrackerAccountRow.swift @@ -12,7 +12,9 @@ struct TrackerAccountRow: View { let onLogout: () -> Void var body: some View { - HStack { + HStack(spacing: 12) { + TrackerLogo(provider: provider) + .frame(width: 28, height: 28) Text(provider.displayName) Spacer() if isPending { diff --git a/KMReader/Features/Tracker/Views/TrackerLinkChip.swift b/KMReader/Features/Tracker/Views/TrackerLinkChip.swift new file mode 100644 index 000000000..c2df44512 --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerLinkChip.swift @@ -0,0 +1,38 @@ +// +// TrackerLinkChip.swift +// + +import SwiftUI + +struct TrackerLinkChip: View { + let link: TrackerLink + let state: TrackerRemoteState? + let requiresLogin: Bool + + var body: some View { + HStack(spacing: 6) { + TrackerLogo(provider: link.provider) + .frame(width: 16, height: 16) + Text(link.provider.displayName) + if requiresLogin { + Circle() + .fill(.red) + .frame(width: 6, height: 6) + .accessibilityLabel(Text(String(localized: "Login Required"))) + } else if let status = state?.status { + Text(status.displayName) + .foregroundStyle(.secondary) + } + } + .font(.caption.weight(.medium)) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(LayoutConfig.neutralFillColor, in: Capsule()) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.2)) + } + .contentShape(Capsule()) + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerLogo.swift b/KMReader/Features/Tracker/Views/TrackerLogo.swift new file mode 100644 index 000000000..708bcc749 --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerLogo.swift @@ -0,0 +1,17 @@ +// +// TrackerLogo.swift +// + +import SwiftUI + +struct TrackerLogo: View { + let provider: TrackerProvider + + var body: some View { + Image(provider.imageName) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 3)) + .accessibilityHidden(true) + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift b/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift index 5b22d6be0..14fa2247b 100644 --- a/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift +++ b/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift @@ -2,6 +2,7 @@ // TrackerSeriesSection.swift // +import Flow import SwiftUI struct TrackerSeriesSection: View { @@ -10,36 +11,42 @@ struct TrackerSeriesSection: View { let seriesTitle: String @State private var links: [TrackerLink] = [] + @State private var states: [TrackerProvider: TrackerRemoteState] = [:] + @State private var providersRequiringLogin: Set = [] + @State private var editingLink: TrackerLink? @State private var showManage = false var body: some View { #if os(iOS) || os(macOS) if TrackerCapabilityStore.shared.isSupported, - TrackerCapabilityStore.shared.allows(libraryId: libraryId), - !links.isEmpty + TrackerCapabilityStore.shared.allows(libraryId: libraryId) { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "Trackers")) - .font(.headline) - Button { - showManage = true - } label: { - HStack { - ForEach(links) { link in - Text(link.provider.displayName) - .font(.caption) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(LayoutConfig.neutralFillColor, in: Capsule()) - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.secondary) + HFlow(itemSpacing: 8) { + ForEach(links) { link in + Button { + editingLink = link + } label: { + TrackerLinkChip( + link: link, + state: states[link.provider], + requiresLogin: providersRequiringLogin.contains(link.provider) + ) } - .contentShape(Rectangle()) + .adaptiveButtonStyle(.plain) + } + if links.count < TrackerProvider.allCases.count { + Button { + showManage = true + } label: { + Label(String(localized: "Add Tracker"), systemImage: "plus") + .font(.caption.weight(.medium)) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .contentShape(Capsule()) + } + .adaptiveButtonStyle(.plain) } - .adaptiveButtonStyle(.plain) } .task(id: seriesId) { await load() @@ -56,6 +63,15 @@ struct TrackerSeriesSection: View { seriesTitle: seriesTitle ) } + .sheet(item: $editingLink) { link in + TrackerLinkEditSheet( + seriesId: seriesId, + seriesTitle: seriesTitle, + link: link + ) { + await load() + } + } } #endif } @@ -63,7 +79,21 @@ struct TrackerSeriesSection: View { private func load() async { guard !AppConfig.isOffline else { return } do { - links = try await TrackerService.getLinks(seriesId: seriesId) + let loadedLinks = try await TrackerService.getLinks(seriesId: seriesId) + guard !Task.isCancelled else { return } + links = loadedLinks + states = [:] + providersRequiringLogin = [] + for link in loadedLinks { + do { + let state = try await TrackerService.getState( + provider: link.provider, trackId: link.trackId) + guard !Task.isCancelled else { return } + states[link.provider] = state + } catch APIError.unauthorized { + providersRequiringLogin.insert(link.provider) + } catch {} + } } catch { links = [] } diff --git a/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift b/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift index 7b55c2a7b..c41036b14 100644 --- a/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift +++ b/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift @@ -8,6 +8,7 @@ struct TrackerSeriesSheet: View { let seriesId: String let libraryId: String let seriesTitle: String + let onLinksChanged: (() async -> Void)? @AppStorage("currentAccount") private var current: Current = .init() @Environment(\.dismiss) private var dismiss @@ -18,6 +19,18 @@ struct TrackerSeriesSheet: View { @State private var isLoading = false @State private var editingLink: TrackerLink? + init( + seriesId: String, + libraryId: String, + seriesTitle: String, + onLinksChanged: (() async -> Void)? = nil + ) { + self.seriesId = seriesId + self.libraryId = libraryId + self.seriesTitle = seriesTitle + self.onLinksChanged = onLinksChanged + } + private var webURL: URL? { URL(string: current.serverURL + "/account/trackers") } @@ -55,7 +68,9 @@ struct TrackerSeriesSheet: View { Button { editingLink = link } label: { - HStack { + HStack(spacing: 12) { + TrackerLogo(provider: link.provider) + .frame(width: 28, height: 28) VStack(alignment: .leading, spacing: 3) { Text(link.provider.displayName) Text(link.displayTitle) @@ -132,6 +147,7 @@ struct TrackerSeriesSheet: View { link: link ) { await load() + await onLinksChanged?() } } } diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index 48c84c0ea..aa3714954 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -46876,6 +46876,70 @@ } } }, + "Login Required" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeldung erforderlich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login Required" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicio de sesión requerido" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion requise" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accesso richiesto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログインが必要です" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인 필요" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется вход" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要登录" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要登入" + } + } + } + }, "Logout" : { "localizations" : { "de" : {