diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 7013e01..f37bdc8 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */; }; 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; }; + AB80F1A1C0DE000000000001 /* ThemePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000002 /* ThemePreference.swift */; }; 73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; }; 745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */; }; 79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; }; @@ -135,6 +136,7 @@ ADE33267E8D212F2863B1ABB /* APIClientSearchUsersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */; }; B1C1D1E1F1A10001 /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10002 /* DataCache.swift */; }; B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10004 /* AppDataStore.swift */; }; + AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */; }; B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10006 /* SkeletonBlock.swift */; }; B1C1D1E1F1A10007 /* FeedSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10008 /* FeedSkeletonView.swift */; }; B1C1D1E1F1A10009 /* ListSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000A /* ListSkeletonView.swift */; }; @@ -396,6 +398,8 @@ A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaEditorView.swift; sourceTree = ""; }; A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaDraft.swift; sourceTree = ""; }; A2B2C2D2E2F20042 /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = ""; }; + BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteStoreTests.swift; sourceTree = ""; }; + AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThemePreferenceTests.swift; sourceTree = ""; }; A2B2C2D2E2F20044 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = ""; }; A2B2C2D2E2F20046 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; A2B2C2D2E2F20048 /* FollowRequestsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestsView.swift; sourceTree = ""; }; @@ -420,7 +424,6 @@ B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredDocumentSheet.swift; sourceTree = ""; }; B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TagDiscovery.swift; sourceTree = ""; }; B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FindPeopleView.swift; sourceTree = ""; }; - BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteStoreTests.swift; sourceTree = ""; }; BADBF836F0B5BD6524776FE7 /* SharingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharingView.swift; sourceTree = ""; }; BD05B653363E3A794B1B1EE7 /* ShareLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLink.swift; sourceTree = ""; }; BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflict.swift; sourceTree = ""; }; @@ -476,6 +479,7 @@ F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributorsView.swift; sourceTree = ""; }; F9CCBC371A0DEF4F2F42A941 /* GitHubMetadata.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GitHubMetadata.swift; sourceTree = ""; }; FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; }; + AB80F1A1C0DE000000000002 /* ThemePreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThemePreference.swift; sourceTree = ""; }; FCCD5BA8427B40000116FC43 /* APIClientDocumentPresenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentPresenceTests.swift; sourceTree = ""; }; FC8B6FBC22A2B1CC69B72ED0 /* ViewPreferences.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewPreferences.swift; sourceTree = ""; }; T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -638,6 +642,7 @@ D0A1D0A1D0A10002 /* OAuthCoordinator.swift */, 1F84433332486544DA15C7BA /* APIClient+Documents.swift */, FC5914D730B4355F02E89D81 /* ILColor.swift */, + AB80F1A1C0DE000000000002 /* ThemePreference.swift */, 8B79D26B822DCA1188DF37E9 /* PushService.swift */, 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */, 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */, @@ -858,6 +863,7 @@ 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */, D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */, BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */, + AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */, 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, @@ -1031,6 +1037,7 @@ CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */, D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */, 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */, + AB80F1A1C0DE000000000001 /* ThemePreference.swift in Sources */, 47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */, C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */, 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */, @@ -1166,6 +1173,7 @@ T1E5T1E5T1E5P029 /* ModerationModelTests.swift in Sources */, 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */, 1F2D51E9719CC8F468BF5107 /* MuteStoreTests.swift in Sources */, + AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */, 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */, E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */, 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */, diff --git a/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json b/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json index c75069e..525d0fa 100644 --- a/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json +++ b/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json @@ -5,15 +5,48 @@ "idiom" : "universal", "scale" : "1x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Logo-Dark.png", + "idiom" : "universal", + "scale" : "1x" + }, { "filename" : "Logo@2x.png", "idiom" : "universal", "scale" : "2x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Logo-Dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "Logo@3x.png", "idiom" : "universal", "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Logo-Dark@3x.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark.png b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark.png new file mode 100644 index 0000000..a0b8736 Binary files /dev/null and b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark.png differ diff --git a/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@2x.png b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@2x.png new file mode 100644 index 0000000..7f4530d Binary files /dev/null and b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@2x.png differ diff --git a/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@3x.png b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@3x.png new file mode 100644 index 0000000..17c6363 Binary files /dev/null and b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@3x.png differ diff --git a/InterlinedList/InterlinedListApp.swift b/InterlinedList/InterlinedListApp.swift index 9d39c6b..a16f429 100644 --- a/InterlinedList/InterlinedListApp.swift +++ b/InterlinedList/InterlinedListApp.swift @@ -52,7 +52,10 @@ struct InterlinedListApp: App { } } .sheet(item: $router.pendingDeepLink) { link in - deepLinkSheet(for: link) + // A sheet inherits the color-scheme override from RootView (it is a + // window-level trait) but not its tint, so brand the sheet's own + // controls here or they render iOS system blue. + deepLinkSheet(for: link).tint(ILColor.link) } } } diff --git a/InterlinedList/Services/AuthState.swift b/InterlinedList/Services/AuthState.swift index a96d970..0e499f7 100644 --- a/InterlinedList/Services/AuthState.swift +++ b/InterlinedList/Services/AuthState.swift @@ -7,7 +7,14 @@ import Foundation @MainActor final class AuthState: ObservableObject { - @Published private(set) var user: User? + @Published private(set) var user: User? { + didSet { + // Every path that loads or updates the account funnels through here, so + // this is the one place the theme mirror is written. Logout (user == nil) + // deliberately leaves it alone: the login screen keeps the last choice. + if let user { themeStore.save(user.theme) } + } + } @Published private(set) var isRestoring = true @Published private(set) var hasToken: Bool = false /// Provider types (e.g. "github", "mastodon") linked to this account, or nil @@ -15,8 +22,10 @@ final class AuthState: ObservableObject { @Published private(set) var linkedProviders: Set? private let api = APIClient.shared + private let themeStore: ThemePreferenceStore - init() { + init(themeStore: ThemePreferenceStore = ThemePreferenceStore()) { + self.themeStore = themeStore if let token = KeychainService.loadToken() { api.setBearerToken(token) hasToken = true diff --git a/InterlinedList/Services/ThemePreference.swift b/InterlinedList/Services/ThemePreference.swift new file mode 100644 index 0000000..f4516df --- /dev/null +++ b/InterlinedList/Services/ThemePreference.swift @@ -0,0 +1,58 @@ +// +// ThemePreference.swift +// InterlinedList +// + +import Foundation +import SwiftUI + +/// The account's display-theme choice, as the server stores it ("system" / "light" / "dark"). +enum ThemePreference: String, CaseIterable { + case system + case light + case dark + + /// `UserDefaults` key for the mirror of the server value. A display preference, + /// not a credential, so the Keychain-only rule for tokens does not apply here. + static let storageKey = "themePreference" + + /// Anything the server (or the mirror) can't be read as a known theme means + /// "follow the OS" — the same fallback `RootView` applied before. + init(stored value: String?) { + self = ThemePreference(rawValue: value?.lowercased() ?? "") ?? .system + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + /// The account value is authoritative once `GET /api/user` has answered; until + /// then — the login screen, a cold launch, an offline start — the mirror stands in. + static func resolve(serverTheme: String?, mirrored: String?) -> ThemePreference { + if let serverTheme { + return ThemePreference(stored: serverTheme) + } + return ThemePreference(stored: mirrored) + } +} + +/// Writes the theme mirror that `RootView` reads at first render. +struct ThemePreferenceStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var current: ThemePreference { + ThemePreference(stored: defaults.string(forKey: ThemePreference.storageKey)) + } + + func save(_ theme: String?) { + defaults.set(ThemePreference(stored: theme).rawValue, forKey: ThemePreference.storageKey) + } +} diff --git a/InterlinedList/Views/RootView.swift b/InterlinedList/Views/RootView.swift index 85a5d44..d14e099 100644 --- a/InterlinedList/Views/RootView.swift +++ b/InterlinedList/Views/RootView.swift @@ -7,6 +7,10 @@ import SwiftUI struct RootView: View { @EnvironmentObject var authState: AuthState + /// Read synchronously at first render, so the saved theme is in force before + /// `GET /api/user` answers — no flash of the OS appearance, and it survives + /// an offline cold start. + @AppStorage(ThemePreference.storageKey) private var mirroredTheme = ThemePreference.system.rawValue var body: some View { Group { @@ -17,6 +21,7 @@ struct RootView: View { } } .preferredColorScheme(preferredScheme) + .tint(ILColor.link) // The server resizes every image upload to its own cap regardless of // what the client sends, so read the caps once at launch and let // ImageUploadProcessor size to them. Public route — no token needed, @@ -24,13 +29,7 @@ struct RootView: View { .task { await ServerLimitsStore.shared.refresh() } } - /// Honor the user's saved theme preference ("light" / "dark"); "system" or - /// missing leaves the OS appearance in control. private var preferredScheme: ColorScheme? { - switch authState.user?.theme { - case "light": return .light - case "dark": return .dark - default: return nil - } + ThemePreference.resolve(serverTheme: authState.user?.theme, mirrored: mirroredTheme).colorScheme } } diff --git a/InterlinedListTests/ServiceTests/ThemePreferenceTests.swift b/InterlinedListTests/ServiceTests/ThemePreferenceTests.swift new file mode 100644 index 0000000..b1ff4b7 --- /dev/null +++ b/InterlinedListTests/ServiceTests/ThemePreferenceTests.swift @@ -0,0 +1,98 @@ +import XCTest +import SwiftUI +@testable import InterlinedList + +final class ThemePreferenceTests: XCTestCase { + private var suiteName = "" + private var defaults = UserDefaults.standard + + override func setUpWithError() throws { + try super.setUpWithError() + suiteName = "ThemePreferenceTests.\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + // MARK: - Parsing + + func test_init_stored_knownValues_parse() { + XCTAssertEqual(ThemePreference(stored: "system"), .system) + XCTAssertEqual(ThemePreference(stored: "light"), .light) + XCTAssertEqual(ThemePreference(stored: "dark"), .dark) + } + + func test_init_stored_mixedCase_parses() { + XCTAssertEqual(ThemePreference(stored: "Dark"), .dark) + } + + func test_init_stored_nil_fallsBackToSystem() { + XCTAssertEqual(ThemePreference(stored: nil), .system) + } + + func test_init_stored_unknownValue_fallsBackToSystem() { + XCTAssertEqual(ThemePreference(stored: "sepia"), .system) + XCTAssertEqual(ThemePreference(stored: ""), .system) + } + + func test_colorScheme_mapsEachCase() { + XCTAssertNil(ThemePreference.system.colorScheme) + XCTAssertEqual(ThemePreference.light.colorScheme, ColorScheme.light) + XCTAssertEqual(ThemePreference.dark.colorScheme, ColorScheme.dark) + } + + // MARK: - Resolution (server value vs. mirror) + + func test_resolve_serverThemePresent_winsOverMirror() { + XCTAssertEqual(ThemePreference.resolve(serverTheme: "light", mirrored: "dark"), .light) + } + + func test_resolve_serverThemeMissing_usesMirror() { + // The login screen and every cold launch before GET /api/user answers. + XCTAssertEqual(ThemePreference.resolve(serverTheme: nil, mirrored: "dark"), .dark) + } + + func test_resolve_serverThemeUnknown_fallsBackToSystemNotMirror() { + XCTAssertEqual(ThemePreference.resolve(serverTheme: "chartreuse", mirrored: "dark"), .system) + } + + func test_resolve_bothMissing_isSystem() { + XCTAssertEqual(ThemePreference.resolve(serverTheme: nil, mirrored: nil), .system) + } + + // MARK: - Mirror persistence + + func test_current_nothingStored_isSystem() { + XCTAssertEqual(ThemePreferenceStore(defaults: defaults).current, .system) + } + + func test_save_thenFreshStore_readsStoredValue() throws { + ThemePreferenceStore(defaults: defaults).save("dark") + + let reopened = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + XCTAssertEqual(ThemePreferenceStore(defaults: reopened).current, .dark) + } + + func test_save_unknownValue_normalizesToSystem() { + let store = ThemePreferenceStore(defaults: defaults) + store.save("dark") + store.save("sepia") + XCTAssertEqual(store.current, .system) + } + + func test_save_nil_normalizesToSystem() { + let store = ThemePreferenceStore(defaults: defaults) + store.save("dark") + store.save(nil) + XCTAssertEqual(store.current, .system) + } + + func test_save_writesRawValueUnderSharedKey() { + // RootView reads the same key through @AppStorage, so the raw string matters. + ThemePreferenceStore(defaults: defaults).save("light") + XCTAssertEqual(defaults.string(forKey: ThemePreference.storageKey), "light") + } +}