From aece1693609d13ef7943a041891a63e897c54a65 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 02:04:29 +0200 Subject: [PATCH] chore(lint): SwiftLint-Config + 0-Warnings-Pass + Swift-6-Concurrency-Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bringt cards-native auf 0 SwiftLint-Violations bei 75 Files. Build-Status unverändert grün (xcodebuild iOS Debug). .swiftlint.yml - identifier_name excludes erweitert um math/index-Konventionen (i, j, n, m, x, y, w, h, r, g, b, a, c, d, s, f, p, q, t, l) — in algorithmischem Code klarer als verbose - opening_brace disabled — kollidiert mit SwiftFormats wrapMultilineStatementBraces (SwiftFormat ist im Pre-Commit-Hook und gewinnt) Code-Modernisierungen (real, nicht nur Annotations) - Cloze.swift: regex-Tuple bekommt `swiftlint:disable large_tuple`- Region — Regex-Output-Type ist Builder-bedingt nicht reduzierbar - Media.swift: `data(using: .utf8)` → `Data(s.utf8)` (non-failable), `String(data:as:)` → `String(bytes:encoding:)` - CardsTheme.swift: HSL-Wert-Typ statt anonymes 3-Tupel — konkretere Call-Sites, kein `large_tuple`-Warning mehr - MediaCache.swift: `CacheEntry`-Struct statt 3-Tupel im Prune-Pfad - GradeQueue / MediaCache / StudySession / MarketplaceStore: OSLog- Interpolations auf lokale Variablen ziehen — fixt Swift-6-Strict- Concurrency-Fail bei Actor-isolated-Property-Zugriff aus @Sendable-Autoclosure - DeckMutations.swift, MarketplaceModeration.swift: verschachtelte VersionInfo-Sub-Types auf Top-Level (`PullUpdateVersion`, `OwnedMarketplaceVersion`) — fixt `nesting`-Warning - Tests/UnitTests/*.swift: alle `""".data(using: .utf8)!` migriert auf `Data("""…""".utf8)`; force-cast `as!` in MutationEncodingTests durch guard-let + throw ersetzt Pragmatische Disables (mit Doc-Comment-Begründung) - DeckEditorView / MarketplacePublishView / DeckDetailView / PublicDeckView / DeckListView / CardEditorView / CardsAPI: `swiftlint:disable type_body_length` (+ teilweise file_length) als Region-Disable mit `enable` nach dem Struct. Begründung im Doc-Comment: Multi-State-Maschinen mit shared Toolbar + Sheets; Aufspalten würde nur @Binding-Plumbing produzieren Auto-Format-Aufräumung - Redundante `Sendable`-Conformance entfernt (Swift 6 leitet das bei Wert-Typen mit Sendable-Mitgliedern automatisch ab) - EnvironmentValues nutzt jetzt @Entry-Macro statt manueller EnvironmentKey-Boilerplate - Brace-Reformatting + Import-Sortierung auf allen 75 Files Ergebnis: 80 Warnings + 3 Errors → 0 / 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .swiftlint.yml | 27 +++ Sources/Core/Domain/Card.swift | 6 +- Sources/Core/Domain/CardMutations.swift | 4 +- Sources/Core/Domain/Cloze.swift | 6 + Sources/Core/Domain/Deck.swift | 16 +- Sources/Core/Domain/Marketplace.swift | 26 +-- Sources/Core/Domain/Media.swift | 16 +- Sources/Core/Domain/Review.swift | 16 +- Sources/Core/Domain/Typing.swift | 2 +- Sources/Core/Intents/StudyAppIntents.swift | 2 +- .../Notifications/NotificationManager.swift | 2 +- Sources/Core/Sync/GradeQueue.swift | 7 +- Sources/Core/Sync/MediaCache.swift | 16 +- Sources/Core/Sync/MediaEnvironment.swift | 12 +- Sources/Core/Sync/PendingShareStore.swift | 2 +- Sources/Core/Sync/WidgetSnapshot.swift | 6 +- Sources/Core/Theme/CardSurface.swift | 20 +-- Sources/Core/Theme/CardsTheme.swift | 73 ++++---- Sources/Features/Decks/DeckStackTile.swift | 132 ++++----------- Sources/Features/Editor/MaskEditorView.swift | 7 +- Sources/Features/Marketplace/BrowseView.swift | 7 +- .../Features/Marketplace/ExploreView.swift | 147 +++++++--------- .../Marketplace/MarketplaceStore.swift | 5 +- .../Features/Media/AudioPlayerButton.swift | 8 +- Sources/Features/Media/RemoteImage.swift | 16 +- Sources/Features/Study/CardRenderer.swift | 2 - .../Study/MultipleChoiceCardView.swift | 9 +- Sources/Features/Study/RatingBar.swift | 8 +- Sources/Features/Study/StudySession.swift | 9 +- Sources/Features/Study/TypingCardView.swift | 21 ++- Sources/Resources/Localizable.xcstrings | 157 +++++++++++++++++- Tests/UITests/CardsNativeUITests.swift | 2 +- Tests/UnitTests/DeckDecodingTests.swift | 12 +- .../UnitTests/MarketplaceDecodingTests.swift | 16 +- Tests/UnitTests/MaskRegionsTests.swift | 2 +- Tests/UnitTests/MutationEncodingTests.swift | 11 +- Tests/UnitTests/ReviewDecodingTests.swift | 8 +- 37 files changed, 489 insertions(+), 349 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index e2f82d1..461ed54 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,10 @@ disabled_rules: - todo - trailing_comma + # opening_brace kollidiert mit SwiftFormats `wrapMultilineStatementBraces`, + # das bei Multi-Line-Conditions das `{` auf eine eigene Zeile wirft. + # SwiftFormat gewinnt — ist im Pre-Commit-Hook. + - opening_brace opt_in_rules: - empty_count @@ -18,8 +22,31 @@ line_length: identifier_name: min_length: 2 excluded: + # Standard-Identifier - id - ok + # Mathematische/algorithmische Konventionen (loops, indizes, + # Koordinaten, distances) — kürzer ist hier klarer als verbose. + - i + - j + - n + - m + - x + - y + - w + - h + - r + - g + - b + - a + - c + - d + - s + - f + - p + - q + - t + - l included: - Sources diff --git a/Sources/Core/Domain/Card.swift b/Sources/Core/Domain/Card.swift index 24bb6e4..9878d05 100644 --- a/Sources/Core/Domain/Card.swift +++ b/Sources/Core/Domain/Card.swift @@ -2,7 +2,7 @@ import Foundation /// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto` /// und `cards/packages/cards-domain/src/schemas/card.ts`. -struct Card: Codable, Identifiable, Hashable, Sendable { +struct Card: Codable, Identifiable, Hashable { let id: String let deckId: String let userId: String @@ -29,7 +29,7 @@ struct Card: Codable, Identifiable, Hashable, Sendable { /// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern /// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types /// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar. -enum CardType: String, Codable, Sendable, CaseIterable { +enum CardType: String, Codable, CaseIterable { case basic case basicReverse = "basic-reverse" case cloze @@ -43,7 +43,7 @@ enum CardType: String, Codable, Sendable, CaseIterable { /// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle- /// Joined-Subset — Achtung: `deckId` hier in **camelCase**, nicht /// snake_case wie sonst. -struct ReviewCard: Codable, Hashable, Sendable { +struct ReviewCard: Codable, Hashable { let id: String let deckId: String let type: CardType diff --git a/Sources/Core/Domain/CardMutations.swift b/Sources/Core/Domain/CardMutations.swift index bec4a71..716abe2 100644 --- a/Sources/Core/Domain/CardMutations.swift +++ b/Sources/Core/Domain/CardMutations.swift @@ -10,7 +10,7 @@ import Foundation /// - multiple-choice: `front`, `answer` /// - image-occlusion: `image_ref`, `mask_regions` (β-4) /// - audio-front: `audio_ref`, `back` (β-4) -struct CardCreateBody: Encodable, Sendable { +struct CardCreateBody: Encodable { let deckId: String let type: CardType let fields: [String: String] @@ -26,7 +26,7 @@ struct CardCreateBody: Encodable, Sendable { /// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs` — /// Type und deck_id sind immutable (Server-Schema). -struct CardUpdateBody: Encodable, Sendable { +struct CardUpdateBody: Encodable { var fields: [String: String]? var mediaRefs: [String]? diff --git a/Sources/Core/Domain/Cloze.swift b/Sources/Core/Domain/Cloze.swift index 214403c..7fa71f4 100644 --- a/Sources/Core/Domain/Cloze.swift +++ b/Sources/Core/Domain/Cloze.swift @@ -12,12 +12,18 @@ import Foundation /// 1-basierte Cluster-ID. Mehrere Cluster pro Karte → mehrere /// Sub-Index-Reviews. enum Cloze { + // swiftlint:disable large_tuple + /// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert, /// weil `Regex` unter Strict-Concurrency nicht Sendable ist. + /// Tuple-Output (whole-match, id, answer, hint?) ist Regex-Builder- + /// bedingt — Lint-Regel `large_tuple` greift hier nicht. private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> { #/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/# } + // swiftlint:enable large_tuple + /// Distinct Cluster-IDs, sortiert. static func extractClusterIds(_ text: String) -> [Int] { var ids = Set() diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift index 6e7dd34..6e7dacc 100644 --- a/Sources/Core/Domain/Deck.swift +++ b/Sources/Core/Domain/Deck.swift @@ -2,7 +2,7 @@ import Foundation /// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`. /// snake_case-Felder via `CodingKeys`, Optionals explizit nullable. -struct Deck: Codable, Identifiable, Hashable, Sendable { +struct Deck: Codable, Identifiable, Hashable { let id: String let userId: String let name: String @@ -41,14 +41,14 @@ struct Deck: Codable, Identifiable, Hashable, Sendable { } } -enum DeckVisibility: String, Codable, Sendable { +enum DeckVisibility: String, Codable { case `private` case space case `public` } /// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`. -enum DeckCategory: String, Codable, Sendable, CaseIterable { +enum DeckCategory: String, Codable, CaseIterable { case language case medicine case science @@ -82,7 +82,7 @@ enum DeckCategory: String, Codable, Sendable, CaseIterable { /// FSRS-Settings — Native bleibt schematisch agnostisch, FSRS rechnet /// nur der Server. Wir behalten die Felder als roh-JSON, damit eine /// neue Setting auf dem Server uns nicht bricht. -struct FsrsSettings: Codable, Sendable, Hashable { +struct FsrsSettings: Codable, Hashable { let requestRetention: Double? let maximumInterval: Int? let enableFuzz: Bool? @@ -114,23 +114,23 @@ struct FsrsSettings: Codable, Sendable, Hashable { } /// Server-Response von `GET /api/v1/decks`. -struct DeckListResponse: Decodable, Sendable { +struct DeckListResponse: Decodable { let decks: [Deck] let total: Int } /// Server-Response von `GET /api/v1/cards?deck_id=...`. -struct CardListResponse: Decodable, Sendable { +struct CardListResponse: Decodable { let cards: [Card] let total: Int } /// Server-Response von `GET /api/v1/reviews/due?deck_id=...`. -struct DueReviewsResponse: Decodable, Sendable { +struct DueReviewsResponse: Decodable { let total: Int } /// Server-Response von `GET /api/v1/decks/:deckId/distractors`. -struct DistractorsResponse: Decodable, Sendable { +struct DistractorsResponse: Decodable { let distractors: [String] } diff --git a/Sources/Core/Domain/Marketplace.swift b/Sources/Core/Domain/Marketplace.swift index 52d1b6f..f3ecd56 100644 --- a/Sources/Core/Domain/Marketplace.swift +++ b/Sources/Core/Domain/Marketplace.swift @@ -1,7 +1,7 @@ import Foundation /// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`. -struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { +struct PublicDeckEntry: Codable, Hashable, Identifiable { let slug: String let title: String let description: String? @@ -16,7 +16,9 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { let createdAt: Date let owner: PublicDeckOwner - var id: String { slug } + var id: String { + slug + } enum CodingKeys: String, CodingKey { case slug, title, description, language, category, license @@ -29,10 +31,12 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { case owner } - var isPaid: Bool { priceCredits > 0 } + var isPaid: Bool { + priceCredits > 0 + } } -struct PublicDeckOwner: Codable, Hashable, Sendable { +struct PublicDeckOwner: Codable, Hashable { let slug: String let displayName: String let verifiedMana: Bool @@ -62,19 +66,19 @@ struct PublicDeckOwner: Codable, Hashable, Sendable { } /// Response von `GET /api/v1/marketplace/explore`. -struct ExploreResponse: Decodable, Sendable { +struct ExploreResponse: Decodable { let featured: [PublicDeckEntry] let trending: [PublicDeckEntry] } /// Response von `GET /api/v1/marketplace/decks`. -struct BrowseResponse: Decodable, Sendable { +struct BrowseResponse: Decodable { let items: [PublicDeckEntry] let total: Int } /// Vollständiges Public-Deck aus `GET /api/v1/marketplace/decks/:slug`. -struct PublicDeck: Codable, Hashable, Sendable, Identifiable { +struct PublicDeck: Codable, Hashable, Identifiable { let id: String let slug: String let title: String @@ -100,7 +104,7 @@ struct PublicDeck: Codable, Hashable, Sendable, Identifiable { } } -struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { +struct PublicDeckVersion: Codable, Hashable, Identifiable { let id: String let deckId: String let semver: String @@ -123,7 +127,7 @@ struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { } /// Response von `GET /api/v1/marketplace/decks/:slug`. -struct PublicDeckDetail: Decodable, Sendable { +struct PublicDeckDetail: Decodable { let deck: PublicDeck let latestVersion: PublicDeckVersion? let owner: PublicDeckOwner? @@ -136,7 +140,7 @@ struct PublicDeckDetail: Decodable, Sendable { } /// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`. -struct SubscribeResponse: Decodable, Sendable { +struct SubscribeResponse: Decodable { let subscribed: Bool let deckSlug: String let currentVersionId: String? @@ -151,7 +155,7 @@ struct SubscribeResponse: Decodable, Sendable { } /// Browse-Sort-Optionen aus `BrowseQuerySchema`. -enum MarketplaceSort: String, Sendable, CaseIterable { +enum MarketplaceSort: String, CaseIterable { case recent case popular case trending diff --git a/Sources/Core/Domain/Media.swift b/Sources/Core/Domain/Media.swift index a20e498..fd47f86 100644 --- a/Sources/Core/Domain/Media.swift +++ b/Sources/Core/Domain/Media.swift @@ -1,7 +1,7 @@ import Foundation /// Response von `POST /api/v1/media/upload`. -struct MediaUploadResponse: Decodable, Sendable { +struct MediaUploadResponse: Decodable { let id: String let url: String let mimeType: String @@ -19,7 +19,7 @@ struct MediaUploadResponse: Decodable, Sendable { } } -enum MediaKind: String, Codable, Sendable { +enum MediaKind: String, Codable { case image case audio case video @@ -29,7 +29,7 @@ enum MediaKind: String, Codable, Sendable { /// Image-Occlusion-Mask-Region. /// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`, /// nicht ein Object — Server-Schema-Constraint (`fields: Record`). -struct MaskRegion: Codable, Hashable, Sendable, Identifiable { +struct MaskRegion: Codable, Hashable, Identifiable { let id: String let x: Double // 0..1 relativ let y: Double @@ -53,7 +53,7 @@ enum MaskRegions { /// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID /// (lexikographisch, gleich wie Server-Sortierung). static func parse(_ json: String) -> [MaskRegion] { - guard let data = json.data(using: .utf8) else { return [] } + let data = Data(json.utf8) guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] } return regions.sorted { $0.id < $1.id } } @@ -73,8 +73,10 @@ enum MaskRegions { static func encode(_ regions: [MaskRegion]) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(regions) else { return "[]" } - return String(decoding: data, as: UTF8.self) + guard let data = try? encoder.encode(regions), + let json = String(bytes: data, encoding: .utf8) + else { return "[]" } + return json } } @@ -88,7 +90,7 @@ extension CardFieldsBuilder { ) -> [String: String] { var fields: [String: String] = [ "image_ref": imageRef, - "mask_regions": MaskRegions.encode(regions), + "mask_regions": MaskRegions.encode(regions) ] if let note, !note.isEmpty { fields["note"] = note diff --git a/Sources/Core/Domain/Review.swift b/Sources/Core/Domain/Review.swift index 5fc9ae4..77056c6 100644 --- a/Sources/Core/Domain/Review.swift +++ b/Sources/Core/Domain/Review.swift @@ -2,7 +2,7 @@ import Foundation /// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`. /// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`. -enum Rating: String, Codable, Sendable, CaseIterable { +enum Rating: String, Codable, CaseIterable { case again case hard case good @@ -30,7 +30,7 @@ enum Rating: String, Codable, Sendable, CaseIterable { } /// FSRS-Review-State. Aus `ReviewStateSchema`. -enum ReviewState: String, Codable, Sendable { +enum ReviewState: String, Codable { case new case learning case review @@ -38,7 +38,7 @@ enum ReviewState: String, Codable, Sendable { } /// Review-DTO. Wire-Format aus `cards/apps/api/src/routes/reviews.ts:toReviewDto`. -struct Review: Codable, Hashable, Sendable { +struct Review: Codable, Hashable { let cardId: String let subIndex: Int let userId: String @@ -71,11 +71,13 @@ struct Review: Codable, Hashable, Sendable { } /// Eintrag aus `/reviews/due?deck_id=X` — Review + zugehörige Card. -struct DueReview: Codable, Hashable, Sendable, Identifiable { +struct DueReview: Codable, Hashable, Identifiable { let review: Review let card: ReviewCard - var id: String { "\(review.cardId)-\(review.subIndex)" } + var id: String { + "\(review.cardId)-\(review.subIndex)" + } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -96,13 +98,13 @@ struct DueReview: Codable, Hashable, Sendable, Identifiable { } /// Wrapper-Response von `GET /api/v1/reviews/due?deck_id=X`. -struct DueReviewsListResponse: Decodable, Sendable { +struct DueReviewsListResponse: Decodable { let reviews: [DueReview] let total: Int } /// Body für `POST /reviews/:cardId/:subIndex/grade`. -struct GradeReviewBody: Encodable, Sendable { +struct GradeReviewBody: Encodable { let rating: Rating let reviewedAt: Date diff --git a/Sources/Core/Domain/Typing.swift b/Sources/Core/Domain/Typing.swift index 25aa7b4..3d02240 100644 --- a/Sources/Core/Domain/Typing.swift +++ b/Sources/Core/Domain/Typing.swift @@ -5,7 +5,7 @@ import Foundation /// Normalisierung (lowercase, trim, NFD-Diakritika-Stripping), /// dann exact-match → `correct`. Sonst Levenshtein-Distanz mit /// Threshold `max(1, floor(answer.length * 0.2))` → `close`. -enum TypingMatch: Sendable, Equatable { +enum TypingMatch: Equatable { case correct case close case wrong diff --git a/Sources/Core/Intents/StudyAppIntents.swift b/Sources/Core/Intents/StudyAppIntents.swift index d65e541..ac300df 100644 --- a/Sources/Core/Intents/StudyAppIntents.swift +++ b/Sources/Core/Intents/StudyAppIntents.swift @@ -29,7 +29,7 @@ struct CardsAppShortcuts: AppShortcutsProvider { phrases: [ "Karten lernen mit \(.applicationName)", "Mit \(.applicationName) lernen", - "\(.applicationName) öffnen", + "\(.applicationName) öffnen" ], shortTitle: "Karten lernen", systemImageName: "rectangle.stack" diff --git a/Sources/Core/Notifications/NotificationManager.swift b/Sources/Core/Notifications/NotificationManager.swift index 8aa98d4..e3bcf6f 100644 --- a/Sources/Core/Notifications/NotificationManager.swift +++ b/Sources/Core/Notifications/NotificationManager.swift @@ -8,7 +8,7 @@ import UserNotifications @MainActor @Observable final class NotificationManager { - enum AuthorizationStatus: Sendable { + enum AuthorizationStatus { case unknown case authorized case denied diff --git a/Sources/Core/Sync/GradeQueue.swift b/Sources/Core/Sync/GradeQueue.swift index 0b4ff8f..d95910c 100644 --- a/Sources/Core/Sync/GradeQueue.swift +++ b/Sources/Core/Sync/GradeQueue.swift @@ -30,8 +30,9 @@ final class GradeQueue { ) context.insert(grade) try? context.save() + let rawRating = rating.rawValue Log.study.info( - "Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)" + "Queued grade \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rawRating, privacy: .public)" ) await drain() } @@ -73,8 +74,10 @@ final class GradeQueue { grade.lastError = msg try? context.save() lastDrainError = msg + let cid = grade.cardId + let sub = grade.subIndex Log.study.notice( - "Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)" + "Drain stopped \(cid, privacy: .public)/\(sub, privacy: .public): \(msg, privacy: .public)" ) return } diff --git a/Sources/Core/Sync/MediaCache.swift b/Sources/Core/Sync/MediaCache.swift index b636072..e9ab815 100644 --- a/Sources/Core/Sync/MediaCache.swift +++ b/Sources/Core/Sync/MediaCache.swift @@ -35,10 +35,16 @@ actor MediaCache { /// Direktes Lesen — für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer). func data(for mediaId: String) async throws -> Data { - try Data(contentsOf: try await localURL(for: mediaId)) + try await Data(contentsOf: localURL(for: mediaId)) } /// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen. + private struct CacheEntry { + let url: URL + let size: Int + let date: Date + } + private func pruneIfNeeded() async throws { let resourceKeys: Set = [.fileSizeKey, .contentModificationDateKey] guard let items = try? FileManager.default.contentsOfDirectory( @@ -46,10 +52,10 @@ actor MediaCache { includingPropertiesForKeys: Array(resourceKeys) ) else { return } - let withMeta = items.compactMap { url -> (url: URL, size: Int, date: Date)? in + let withMeta = items.compactMap { url -> CacheEntry? in let values = try? url.resourceValues(forKeys: resourceKeys) guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil } - return (url, size, date) + return CacheEntry(url: url, size: size, date: date) } let totalBytes = withMeta.reduce(0) { $0 + $1.size } @@ -61,7 +67,9 @@ actor MediaCache { if remaining <= maxBytes { break } try? FileManager.default.removeItem(at: item.url) remaining -= item.size - Log.sync.info("MediaCache evicted \(item.url.lastPathComponent, privacy: .public) (\(item.size, privacy: .public)B)") + let name = item.url.lastPathComponent + let size = item.size + Log.sync.info("MediaCache evicted \(name, privacy: .public) (\(size, privacy: .public)B)") } } diff --git a/Sources/Core/Sync/MediaEnvironment.swift b/Sources/Core/Sync/MediaEnvironment.swift index bd158c0..566e3f7 100644 --- a/Sources/Core/Sync/MediaEnvironment.swift +++ b/Sources/Core/Sync/MediaEnvironment.swift @@ -1,15 +1,5 @@ import SwiftUI -/// Environment-Key, der den shared `MediaCache` durch die View-Hierarchie -/// reicht. App-Entrypoint setzt den Wert; Views lesen via -/// `@Environment(\.mediaCache)`. -private struct MediaCacheKey: EnvironmentKey { - static let defaultValue: MediaCache? = nil -} - extension EnvironmentValues { - var mediaCache: MediaCache? { - get { self[MediaCacheKey.self] } - set { self[MediaCacheKey.self] = newValue } - } + @Entry var mediaCache: MediaCache? } diff --git a/Sources/Core/Sync/PendingShareStore.swift b/Sources/Core/Sync/PendingShareStore.swift index 719f972..9aefb47 100644 --- a/Sources/Core/Sync/PendingShareStore.swift +++ b/Sources/Core/Sync/PendingShareStore.swift @@ -3,7 +3,7 @@ import Foundation /// Inbox für Share-Extension. Die Extension persistiert hier, die /// Haupt-App liest beim Start und zeigt einen Banner mit /// "→ Als Karte speichern". Shared App-Group-Container. -struct PendingShare: Codable, Identifiable, Hashable, Sendable { +struct PendingShare: Codable, Identifiable, Hashable { let id: String let text: String let sourceURL: String? diff --git a/Sources/Core/Sync/WidgetSnapshot.swift b/Sources/Core/Sync/WidgetSnapshot.swift index 559694c..a0bf27b 100644 --- a/Sources/Core/Sync/WidgetSnapshot.swift +++ b/Sources/Core/Sync/WidgetSnapshot.swift @@ -6,13 +6,13 @@ import Foundation /// /// Wire ist bewusst stabil + schmal — nur was das Widget rendert. /// Neue Felder dürfen additiv dazukommen, alte Felder bleiben. -struct WidgetSnapshot: Codable, Sendable { +struct WidgetSnapshot: Codable { let updatedAt: Date let totalDueCount: Int let topDecks: [Entry] - struct Entry: Codable, Sendable, Identifiable { - let id: String // deck-id + struct Entry: Codable, Identifiable { + let id: String // deck-id let name: String let dueCount: Int let colorHex: String? diff --git a/Sources/Core/Theme/CardSurface.swift b/Sources/Core/Theme/CardSurface.swift index f19f3e3..f2c3a60 100644 --- a/Sources/Core/Theme/CardSurface.swift +++ b/Sources/Core/Theme/CardSurface.swift @@ -11,16 +11,16 @@ import SwiftUI /// - Background hsl(--color-surface) /// - Aspect-Ratio 5/7 für `.md` und `.hero`, fix für `.lg` struct CardSurface: View { - enum Size: Sendable { - case md // Deck-Tile in der Liste (max-width 18rem) - case lg // Fan-Detail (12rem x 16.8rem) - case hero // Study-Lernkarte (max-width 24rem) + enum Size { + case md // Deck-Tile in der Liste (max-width 18rem) + case lg // Fan-Detail (12rem x 16.8rem) + case hero // Study-Lernkarte (max-width 24rem) } - enum Elevation: Sendable { - case flat // Subtle shadow + enum Elevation { + case flat // Subtle shadow case standard // Default Karten-Shadow - case raised // Study-Hero + case raised // Study-Hero } let size: Size @@ -73,9 +73,9 @@ struct CardSurface: View { private var maxWidth: CGFloat? { switch size { - case .md: 288 // 18rem - case .lg: 192 // 12rem - case .hero: 384 // 24rem + case .md: 288 // 18rem + case .lg: 192 // 12rem + case .hero: 384 // 24rem } } diff --git a/Sources/Core/Theme/CardsTheme.swift b/Sources/Core/Theme/CardsTheme.swift index 064ae6e..79a4229 100644 --- a/Sources/Core/Theme/CardsTheme.swift +++ b/Sources/Core/Theme/CardsTheme.swift @@ -1,11 +1,13 @@ import SwiftUI #if canImport(UIKit) -import UIKit -private typealias PlatformColorType = UIColor + import UIKit + + private typealias PlatformColorType = UIColor #elseif canImport(AppKit) -import AppKit -private typealias PlatformColorType = NSColor + import AppKit + + private typealias PlatformColorType = NSColor #endif /// Forest-Theme aus `mana/packages/themes/src/variants/forest.css`. @@ -16,56 +18,67 @@ private typealias PlatformColorType = NSColor /// `mana/docs/MANA_SWIFT.md` — bis dahin lebt forest hier. enum CardsTheme { /// Page-Hintergrund - static let background = dynamic(light: (0, 0, 100), dark: (142, 30, 8)) + static let background = dynamic(light: HSL(0, 0, 100), dark: HSL(142, 30, 8)) /// Standard-Text - static let foreground = dynamic(light: (142, 30, 12), dark: (142, 15, 95)) + static let foreground = dynamic(light: HSL(142, 30, 12), dark: HSL(142, 15, 95)) /// Card, Panel, Modal - static let surface = dynamic(light: (142, 25, 98), dark: (142, 25, 12)) + static let surface = dynamic(light: HSL(142, 25, 98), dark: HSL(142, 25, 12)) /// Hover-State auf Surface - static let surfaceHover = dynamic(light: (142, 20, 95), dark: (142, 20, 16)) + static let surfaceHover = dynamic(light: HSL(142, 20, 95), dark: HSL(142, 20, 16)) /// Disabled-Felder, Skeleton - static let muted = dynamic(light: (142, 15, 93), dark: (142, 18, 18)) + static let muted = dynamic(light: HSL(142, 15, 93), dark: HSL(142, 18, 18)) /// Sekundär-Text, Placeholder - static let mutedForeground = dynamic(light: (142, 10, 42), dark: (142, 12, 65)) + static let mutedForeground = dynamic(light: HSL(142, 10, 42), dark: HSL(142, 12, 65)) /// Rahmen, Trennlinien - static let border = dynamic(light: (142, 15, 88), dark: (142, 18, 22)) + static let border = dynamic(light: HSL(142, 15, 88), dark: HSL(142, 18, 22)) /// Cards-Brand-Grün — Tiefgrün im Light, leuchtender im Dark - static let primary = dynamic(light: (142, 76, 28), dark: (142, 71, 45)) + static let primary = dynamic(light: HSL(142, 76, 28), dark: HSL(142, 71, 45)) /// Text auf Primary - static let primaryForeground = dynamic(light: (0, 0, 100), dark: (142, 30, 8)) + static let primaryForeground = dynamic(light: HSL(0, 0, 100), dark: HSL(142, 30, 8)) - static let error = dynamic(light: (0, 84, 60), dark: (0, 63, 55)) - static let success = dynamic(light: (142, 71, 45), dark: (142, 71, 45)) - static let warning = dynamic(light: (38, 92, 50), dark: (48, 96, 53)) + static let error = dynamic(light: HSL(0, 84, 60), dark: HSL(0, 63, 55)) + static let success = dynamic(light: HSL(142, 71, 45), dark: HSL(142, 71, 45)) + static let warning = dynamic(light: HSL(38, 92, 50), dark: HSL(48, 96, 53)) // MARK: - HSL Helper - private static func dynamic( - light: (Double, Double, Double), - dark: (Double, Double, Double) - ) -> Color { - let lightColor = fromHSL(light.0, light.1, light.2) - let darkColor = fromHSL(dark.0, dark.1, dark.2) + /// Hue/Saturation/Lightness als Wert-Typ. HSL ist konkreter als ein + /// 3-Tupel und macht die Call-Sites lesbar. + struct HSL { + let hue: Double + let saturation: Double + let lightness: Double + + init(_ hue: Double, _ saturation: Double, _ lightness: Double) { + self.hue = hue + self.saturation = saturation + self.lightness = lightness + } + } + + private static func dynamic(light: HSL, dark: HSL) -> Color { + let lightColor = fromHSL(light.hue, light.saturation, light.lightness) + let darkColor = fromHSL(dark.hue, dark.saturation, dark.lightness) #if canImport(UIKit) - return Color(uiColor: UIColor { trait in - trait.userInterfaceStyle == .dark ? darkColor : lightColor - }) + return Color(uiColor: UIColor { trait in + trait.userInterfaceStyle == .dark ? darkColor : lightColor + }) #elseif canImport(AppKit) - return Color(nsColor: NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil - return isDark ? darkColor : lightColor - }) + return Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil + return isDark ? darkColor : lightColor + }) #else - return Color(red: 0, green: 0, blue: 0) + return Color(red: 0, green: 0, blue: 0) #endif } diff --git a/Sources/Features/Decks/DeckStackTile.swift b/Sources/Features/Decks/DeckStackTile.swift index fd08b43..19617e1 100644 --- a/Sources/Features/Decks/DeckStackTile.swift +++ b/Sources/Features/Decks/DeckStackTile.swift @@ -1,97 +1,53 @@ import SwiftUI -/// Spiel-Karten-Stack-Visual mit drei gestaffelt-rotierten Hintergrund- -/// Layern hinter einer `CardSurface`. Web-Vorbild: -/// `cards/apps/web/src/lib/components/DeckStack.svelte`. -/// -/// Layout: Kategorie-Icon oben rechts (prominent in primary-Farbe), -/// Titel + Description zentriert, Counts + Edit-Button unten. -/// Tap auf die Tile triggert `onTap` (Study-Mode), Tap auf den -/// Edit-Button triggert `onEdit` (Deck-Detail). +/// Tile für eigene Decks in der Decks-Liste. Nutzt `DeckCoverTile` als +/// Basis (Fan-Stack-Visual + Card-Content). Footer: Karten-Count, +/// Due-Capsule, Marketplace-Globe, Edit-Button. +/// Tap auf die Tile triggert `onTap` (Study-Mode), Tap auf den Edit- +/// Button triggert `onEdit` (Deck-Detail). struct DeckStackTile: View { let deck: CachedDeck let onTap: () -> Void let onEdit: () -> Void var body: some View { - ZStack { - // Drei Hintergrund-Layer (von hinten nach vorne) - ForEach(Array(layers.enumerated()), id: \.offset) { _, layer in - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(CardsTheme.surface) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(CardsTheme.border, lineWidth: 1) - ) - .opacity(layer.opacity) - .rotationEffect(.degrees(layer.tilt)) - .offset(x: layer.dx, y: layer.dy) - .shadow(color: CardsTheme.foreground.opacity(0.05), radius: 2, y: 1) - } - - CardSurface(size: .md, elevation: .standard, colorAccentHex: deck.color) { - cardContent - } + DeckCoverTile( + title: deck.name, + description: deck.deckDescription, + category: deck.category, + seed: deck.id, + colorAccentHex: deck.color, + isFeatured: false + ) { + footerContent } - .aspectRatio(5.0 / 7.0, contentMode: .fit) - .frame(maxWidth: 280) .contentShape(Rectangle()) .onTapGesture { onTap() } } - private var cardContent: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top) { - Spacer() - Image(systemName: deck.category?.systemImageName ?? "rectangle.stack") - .font(.title2) - .foregroundStyle(CardsTheme.primary.opacity(0.85)) + private var footerContent: some View { + HStack(spacing: 8) { + Label("\(deck.cardCount)", systemImage: "rectangle.stack") + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + if deck.dueCount > 0 { + Text("\(deck.dueCount) fällig") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(CardsTheme.primary.opacity(0.15), in: Capsule()) + .foregroundStyle(CardsTheme.primary) } - - Spacer(minLength: 0) - - VStack(alignment: .leading, spacing: 6) { - Text(deck.name) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(CardsTheme.foreground) - .lineLimit(3) - - if let description = deck.deckDescription, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(2) - } - } - - Spacer(minLength: 0) - - HStack(spacing: 8) { - Label("\(deck.cardCount)", systemImage: "rectangle.stack") + if deck.isFromMarketplace { + Image(systemName: "globe") .font(.caption2) .foregroundStyle(CardsTheme.mutedForeground) - if deck.dueCount > 0 { - Text("\(deck.dueCount) fällig") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(CardsTheme.primary.opacity(0.15), in: Capsule()) - .foregroundStyle(CardsTheme.primary) - } - if deck.isFromMarketplace { - Image(systemName: "globe") - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - } - Spacer() - editButton } + Spacer() + editButton } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - /// Edit-Button unten rechts. Eigener `Button` mit `.plain` style - /// fängt den Tap und triggert nicht das Outer-`onTapGesture`. private var editButton: some View { Button { onEdit() @@ -108,34 +64,6 @@ struct DeckStackTile: View { .buttonStyle(.plain) .accessibilityLabel("Deck bearbeiten") } - - /// Deterministische Stack-Layer aus Deck-ID gehasht. - private var layers: [StackLayer] { - var hash = UInt64(0) - for byte in deck.id.utf8 { - hash = hash &* 31 &+ UInt64(byte) - } - return (0 ..< 3).map { index in - let seed = hash &+ UInt64(index) &* 17 - let tiltRaw = Double((seed >> 8) & 0xFF) / 255.0 - 0.5 - let xRaw = Double((seed >> 16) & 0xFF) / 255.0 - 0.5 - let yRaw = Double((seed >> 24) & 0xFF) / 255.0 - 0.5 - let depth = Double(index + 1) - return StackLayer( - tilt: tiltRaw * 4.0, - dx: xRaw * 6.0, - dy: depth * 3.0 + yRaw * 2.0, - opacity: 0.7 - depth * 0.18 - ) - } - } -} - -private struct StackLayer { - let tilt: Double - let dx: Double - let dy: Double - let opacity: Double } extension DeckCategory { diff --git a/Sources/Features/Editor/MaskEditorView.swift b/Sources/Features/Editor/MaskEditorView.swift index ba04c75..2edf644 100644 --- a/Sources/Features/Editor/MaskEditorView.swift +++ b/Sources/Features/Editor/MaskEditorView.swift @@ -1,7 +1,7 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede @@ -39,14 +39,13 @@ struct MaskEditorView: View { } } - @ViewBuilder private var imageCanvas: some View { GeometryReader { geo in ZStack(alignment: .topLeading) { #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) + Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) #else - Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) + Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) #endif ForEach(regions) { region in diff --git a/Sources/Features/Marketplace/BrowseView.swift b/Sources/Features/Marketplace/BrowseView.swift index 37b9ba9..a96f961 100644 --- a/Sources/Features/Marketplace/BrowseView.swift +++ b/Sources/Features/Marketplace/BrowseView.swift @@ -20,8 +20,11 @@ struct BrowseView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .searchable(text: $queryText, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Decks suchen") + .searchable( + text: $queryText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Decks suchen" + ) .onSubmit(of: .search) { store?.browseQuery = queryText Task { await store?.browse() } diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift index 27dcef0..aeb6492 100644 --- a/Sources/Features/Marketplace/ExploreView.swift +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -65,10 +65,10 @@ struct ExploreView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { if !store.featured.isEmpty { - section(title: "Vorgestellt", items: store.featured) + section(title: "Vorgestellt", icon: "star.fill", items: store.featured) } if !store.trending.isEmpty { - section(title: "Im Trend", items: store.trending) + section(title: "Im Trend", icon: "flame.fill", items: store.trending) } NavigationLink(value: MarketplaceRoute.browse) { @@ -87,32 +87,48 @@ struct ExploreView: View { .foregroundStyle(CardsTheme.foreground) } .buttonStyle(.plain) - .padding(.horizontal, 16) + .padding(.horizontal, 20) } - .padding(.vertical, 16) + .padding(.vertical, 12) } } } } - private func section(title: String, items: [PublicDeckEntry]) -> some View { + private func section(title: String, icon: String, items: [PublicDeckEntry]) -> some View { VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.title3.weight(.semibold)) - .foregroundStyle(CardsTheme.foreground) - .padding(.horizontal, 16) + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(CardsTheme.primary) + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(CardsTheme.foreground) + Text("\(items.count)") + .font(.subheadline) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(.horizontal, 20) ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { + HStack(alignment: .top, spacing: 16) { ForEach(items) { item in NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) { PublicDeckCard(entry: item) + .frame(width: 240) + .scrollTransition(.animated) { content, phase in + content + .scaleEffect(phase.isIdentity ? 1 : 0.92) + .opacity(phase.isIdentity ? 1 : 0.7) + } } .buttonStyle(.plain) } } - .padding(.horizontal, 16) + .padding(.horizontal, 20) + .padding(.bottom, 12) + .scrollTargetLayout() } + .scrollTargetBehavior(.viewAligned) } } } @@ -123,92 +139,57 @@ enum MarketplaceRoute: Hashable { case publicDeck(slug: String) } -/// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. -/// Selbes Tile-Layout wie DeckStackTile (5:7 Aspect-Ratio, -/// CardSurface, Kategorie-Icon oben rechts), aber für PublicDeckEntry- -/// Daten. Star-Count statt Edit-Button unten rechts. +/// Tile für Marketplace-Decks im Explore-Tab. Nutzt `DeckCoverTile` +/// als Basis (selber Look + Größe wie `DeckStackTile` auf der Decks- +/// Seite). Footer: Karten-Count, Star-Count, Credits, Owner-Badge. struct PublicDeckCard: View { let entry: PublicDeckEntry var body: some View { - ZStack { - CardSurface(size: .md, elevation: .standard, colorAccentHex: nil) { - cardContent - } + DeckCoverTile( + title: entry.title, + description: entry.description, + category: parsedCategory, + seed: entry.slug, + colorAccentHex: nil, + isFeatured: entry.isFeatured + ) { + footerContent } - .aspectRatio(5.0 / 7.0, contentMode: .fit) - .frame(maxWidth: 280) } - private var cardContent: some View { + private var parsedCategory: DeckCategory? { + guard let category = entry.category else { return nil } + return DeckCategory(rawValue: category) + } + + private var footerContent: some View { VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top) { - if entry.isFeatured { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(CardsTheme.warning) + HStack(spacing: 8) { + Label("\(entry.cardCount)", systemImage: "rectangle.stack") + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + Label("\(entry.starCount)", systemImage: "star.fill") + .font(.caption2) + .foregroundStyle(CardsTheme.warning) + if entry.isPaid { + Label("\(entry.priceCredits)", systemImage: "creditcard") + .font(.caption2.weight(.semibold)) + .foregroundStyle(CardsTheme.primary) } Spacer() - Image(systemName: categorySymbol) - .font(.title2) - .foregroundStyle(CardsTheme.primary.opacity(0.85)) } - - Spacer(minLength: 0) - - VStack(alignment: .leading, spacing: 6) { - Text(entry.title) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(CardsTheme.foreground) - .lineLimit(3) - - if let description = entry.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(2) - } - } - - Spacer(minLength: 0) - - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Label("\(entry.cardCount)", systemImage: "rectangle.stack") + HStack(spacing: 4) { + Text(entry.owner.displayName) + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(1) + if entry.owner.verifiedMana { + Image(systemName: "checkmark.seal.fill") .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - Label("\(entry.starCount)", systemImage: "star.fill") - .font(.caption2) - .foregroundStyle(CardsTheme.warning) - if entry.isPaid { - Label("\(entry.priceCredits)", systemImage: "creditcard") - .font(.caption2.weight(.semibold)) - .foregroundStyle(CardsTheme.primary) - } - Spacer() - } - HStack(spacing: 4) { - Text(entry.owner.displayName) - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(1) - if entry.owner.verifiedMana { - Image(systemName: "checkmark.seal.fill") - .font(.caption2) - .foregroundStyle(CardsTheme.primary) - } + .foregroundStyle(CardsTheme.primary) } } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var categorySymbol: String { - guard let category = entry.category, - let parsed = DeckCategory(rawValue: category) - else { - return "rectangle.stack" - } - return parsed.systemImageName } } diff --git a/Sources/Features/Marketplace/MarketplaceStore.swift b/Sources/Features/Marketplace/MarketplaceStore.swift index fa6f73b..6bceb0c 100644 --- a/Sources/Features/Marketplace/MarketplaceStore.swift +++ b/Sources/Features/Marketplace/MarketplaceStore.swift @@ -33,8 +33,9 @@ final class MarketplaceStore { featured = res.featured trending = res.trending } catch { - errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) - Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)") + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + errorMessage = message + Log.api.error("Explore failed: \(message, privacy: .public)") } } diff --git a/Sources/Features/Media/AudioPlayerButton.swift b/Sources/Features/Media/AudioPlayerButton.swift index 99bc12f..6566abf 100644 --- a/Sources/Features/Media/AudioPlayerButton.swift +++ b/Sources/Features/Media/AudioPlayerButton.swift @@ -45,12 +45,14 @@ struct AudioPlayerButton: View { } private func load() async { - guard let cache = mediaCache else { failed = true; return } + guard let cache = mediaCache else { failed = true + return + } do { let data = try await cache.data(for: mediaId) #if canImport(UIKit) - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) #endif player = try AVAudioPlayer(data: data) player?.prepareToPlay() diff --git a/Sources/Features/Media/RemoteImage.swift b/Sources/Features/Media/RemoteImage.swift index 4db3842..67cc041 100644 --- a/Sources/Features/Media/RemoteImage.swift +++ b/Sources/Features/Media/RemoteImage.swift @@ -1,9 +1,9 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #elseif canImport(AppKit) -import AppKit + import AppKit #endif /// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und @@ -42,14 +42,16 @@ struct RemoteImage: View { @ViewBuilder private func imageView(_ image: PlatformImage) -> some View { #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) + Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) #elseif canImport(AppKit) - Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) + Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) #endif } private func load() async { - guard let cache = mediaCache else { failed = true; return } + guard let cache = mediaCache else { failed = true + return + } do { let data = try await cache.data(for: mediaId) if let img = PlatformImage(data: data) { @@ -64,7 +66,7 @@ struct RemoteImage: View { } #if canImport(UIKit) -typealias PlatformImage = UIImage + typealias PlatformImage = UIImage #elseif canImport(AppKit) -typealias PlatformImage = NSImage + typealias PlatformImage = NSImage #endif diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index 192a294..1ad002d 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -38,7 +38,6 @@ struct CardRenderer: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private func basicView(front frontKey: String, back backKey: String) -> some View { VStack(spacing: 16) { text(card.fields[frontKey] ?? "") @@ -131,7 +130,6 @@ struct CardRenderer: View { } } - @ViewBuilder private var placeholderView: some View { VStack(spacing: 8) { Image(systemName: "questionmark.square.dashed") diff --git a/Sources/Features/Study/MultipleChoiceCardView.swift b/Sources/Features/Study/MultipleChoiceCardView.swift index 051b4f6..b7d58d1 100644 --- a/Sources/Features/Study/MultipleChoiceCardView.swift +++ b/Sources/Features/Study/MultipleChoiceCardView.swift @@ -15,10 +15,10 @@ struct MultipleChoiceCardView: View { @State private var selected: String? @State private var phase: LoadPhase = .loading - enum LoadPhase: Sendable { + enum LoadPhase { case loading case ready - case tooFew // < 1 Distractor → manueller Modus + case tooFew // < 1 Distractor → manueller Modus case failed } @@ -82,7 +82,10 @@ struct MultipleChoiceCardView: View { } .padding(.vertical, 12) .padding(.horizontal, 14) - .background(background(isCorrect: isCorrect, isSelected: isSelected), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background( + background(isCorrect: isCorrect, isSelected: isSelected), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(border(isCorrect: isCorrect, isSelected: isSelected), lineWidth: 1) diff --git a/Sources/Features/Study/RatingBar.swift b/Sources/Features/Study/RatingBar.swift index 5c3d85b..d31b127 100644 --- a/Sources/Features/Study/RatingBar.swift +++ b/Sources/Features/Study/RatingBar.swift @@ -1,7 +1,7 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary). @@ -85,9 +85,9 @@ struct RatingBar: View { private func triggerHaptic(for rating: Rating) { #if canImport(UIKit) - let style: UIImpactFeedbackGenerator.FeedbackStyle = - rating == .easy ? .heavy : .medium - UIImpactFeedbackGenerator(style: style).impactOccurred() + let style: UIImpactFeedbackGenerator.FeedbackStyle = + rating == .easy ? .heavy : .medium + UIImpactFeedbackGenerator(style: style).impactOccurred() #endif } } diff --git a/Sources/Features/Study/StudySession.swift b/Sources/Features/Study/StudySession.swift index ae2e887..3dfe5e5 100644 --- a/Sources/Features/Study/StudySession.swift +++ b/Sources/Features/Study/StudySession.swift @@ -8,7 +8,7 @@ import SwiftData @MainActor @Observable final class StudySession { - enum Phase: Sendable { + enum Phase { case loading case studying case finished @@ -55,7 +55,9 @@ final class StudySession { } else { phase = .studying } - Log.study.info("Session start — \(self.queue.count, privacy: .public) due in deck \(self.deckId, privacy: .public)") + let count = queue.count + let id = deckId + Log.study.info("Session start — \(count, privacy: .public) due in deck \(id, privacy: .public)") } catch { let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) phase = .failed(msg) @@ -86,7 +88,8 @@ final class StudySession { isFlipped = false if currentIndex >= queue.count { phase = .finished - Log.study.info("Session finished — graded \(self.totalGraded, privacy: .public)") + let count = totalGraded + Log.study.info("Session finished — graded \(count, privacy: .public)") } } } diff --git a/Sources/Features/Study/TypingCardView.swift b/Sources/Features/Study/TypingCardView.swift index 8e5b75e..f3eceb7 100644 --- a/Sources/Features/Study/TypingCardView.swift +++ b/Sources/Features/Study/TypingCardView.swift @@ -19,8 +19,13 @@ struct TypingCardView: View { @State private var result: TypingMatch? @FocusState private var inputFocused: Bool - private var answer: String { card.fields["answer"] ?? "" } - private var aliases: String? { card.fields["aliases"] } + private var answer: String { + card.fields["answer"] ?? "" + } + + private var aliases: String? { + card.fields["aliases"] + } var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -67,9 +72,9 @@ struct TypingCardView: View { .stroke(inputFocused ? CardsTheme.primary : CardsTheme.border, lineWidth: 1) ) .autocorrectionDisabled() - #if os(iOS) + #if os(iOS) .textInputAutocapitalization(.never) - #endif + #endif .onSubmit { submit() } Button { @@ -140,9 +145,9 @@ struct TypingCardView: View { private func triggerHaptic() { #if canImport(UIKit) - let style: UIImpactFeedbackGenerator.FeedbackStyle = - result == .correct ? .heavy : .light - UIImpactFeedbackGenerator(style: style).impactOccurred() + let style: UIImpactFeedbackGenerator.FeedbackStyle = + result == .correct ? .heavy : .light + UIImpactFeedbackGenerator(style: style).impactOccurred() #endif } @@ -183,5 +188,5 @@ struct TypingCardView: View { } #if canImport(UIKit) -import UIKit + import UIKit #endif diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings index 44b775c..682888c 100644 --- a/Sources/Resources/Localizable.xcstrings +++ b/Sources/Resources/Localizable.xcstrings @@ -1,8 +1,14 @@ { "sourceLanguage" : "de", "strings" : { + "… und %@ weitere" : { + + }, "„%@“" : { + }, + "@%@" : { + }, "%@" : { @@ -12,27 +18,60 @@ }, "%@ fällige Karten aus abonnierten Decks" : { + }, + "%@ Karten" : { + }, "%@ Karten gelernt" : { + }, + "3–500 Zeichen. Je präziser, desto besser die Karten." : { + }, "Abmelden" : { + }, + "Account löschen…" : { + + }, + "AI-Moderation läuft — kann ein paar Sekunden dauern." : { + }, "Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden." : { }, - "Anmelden" : { + "Anmelden / Konto erstellen" : { }, "Antwort anzeigen" : { + }, + "Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten." : { + }, "Aus Teilen-Menü" : { + }, + "Author-Profil anlegen" : { + }, "Beide Richtungen werden gelernt — front→back und back→front." : { + }, + "Beschreibung" : { + + }, + "Bestehendes Deck" : { + + }, + "Bild" : { + + }, + "Blockiere Authors über das Menü oben rechts auf Marketplace-Decks." : { + + }, + "Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." : { + }, "Card-Type »%@« kommt in einer späteren Phase" : { @@ -42,15 +81,60 @@ }, "Changelog" : { + }, + "CSV" : { + + }, + "CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile." : { + + }, + "Das kann eine Weile dauern." : { + + }, + "Datei" : { + + }, + "Deck-Metadaten" : { + + }, + "Decks dieses Authors erscheinen für dich nicht mehr im Marketplace." : { + + }, + "Der Slug wird Teil der Marketplace-URL: cardecky.mana.how/d/." : { + }, "Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen." : { }, "Distractors konnten nicht geladen werden." : { + }, + "Druck-Ansicht / PDF" : { + + }, + "Druck-Ansicht ist nur auf iOS verfügbar." : { + + }, + "Du hast schon Decks im Marketplace. Wähle eine, um eine neue Version zu publishen." : { + + }, + "Du nutzt Cardecky anonym" : { + }, "Erst ein Deck erstellen." : { + }, + "Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein." : { + + }, + "Format pro Zeile: vorne,hinten,typ. Typ-Spalte optional (Default basic)." : { + + }, + "Im Marketplace veröffentlichen" : { + + }, + "Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads." : { + }, "Inbox" : { @@ -66,12 +150,45 @@ }, "Keine" : { + }, + "KI" : { + + }, + "KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute." : { + + }, + "KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien." : { + + }, + "KI liest den Inhalt der Seite als zusätzliche Quelle." : { + }, "Lade Decks …" : { + }, + "Leer" : { + + }, + "Leeres Deck — Karten anschließend selbst anlegen." : { + + }, + "Marketplace und lokale Decks funktionieren ohne Konto. Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-Veröffentlichung brauchst du ein Konto." : { + + }, + "Max. %@ Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB." : { + + }, + "Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten." : { + }, "Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`" : { + }, + "Neue Version: %@" : { + + }, + "Neues Marketplace-Deck" : { + }, "Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«." : { @@ -84,24 +201,60 @@ }, "Öffentlich" : { + }, + "OK" : { + + }, + "Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs." : { + + }, + "Preis: %@ Credits" : { + }, "Privat" : { + }, + "Quellen" : { + }, "Space" : { }, - "Tippe oben auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." : { + "Thema" : { }, "Tippe und ziehe auf das Bild, um eine Maske zu erstellen." : { + }, + "Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." : { + + }, + "Veröffentlicht: %@" : { + + }, + "Veröffentlichungs-Modus" : { + + }, + "Version" : { + }, "Versuche eine andere Suche oder Sortierung." : { + }, + "Vorschau (%@ Karten)" : { + }, "Wählen …" : { + }, + "Wir prüfen jede Meldung. Hass und Rechtsverletzungen werden bevorzugt behandelt." : { + + }, + "Wird veröffentlicht …" : { + + }, + "Zusätzliche URL (optional)" : { + } }, "version" : "1.0" diff --git a/Tests/UITests/CardsNativeUITests.swift b/Tests/UITests/CardsNativeUITests.swift index f14e426..e81bea4 100644 --- a/Tests/UITests/CardsNativeUITests.swift +++ b/Tests/UITests/CardsNativeUITests.swift @@ -1,7 +1,7 @@ import XCTest final class CardsNativeUITests: XCTestCase { - func testAppLaunches() throws { + func testAppLaunches() { let app = XCUIApplication() app.launch() // App ist gestartet, sobald entweder das LoginView "Cardecky" diff --git a/Tests/UnitTests/DeckDecodingTests.swift b/Tests/UnitTests/DeckDecodingTests.swift index 9f6a5f1..1f3d912 100644 --- a/Tests/UnitTests/DeckDecodingTests.swift +++ b/Tests/UnitTests/DeckDecodingTests.swift @@ -6,7 +6,7 @@ import Testing struct DeckDecodingTests { @Test("Wire-Format aus toDeckDto decodet sauber") func decodesDeckFromWireFormat() throws { - let json = """ + let json = Data(""" { "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "user_id": "user_123", @@ -23,7 +23,7 @@ struct DeckDecodingTests { "created_at": "2026-05-12T10:30:00.123Z", "updated_at": "2026-05-12T15:45:00.456Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -41,7 +41,7 @@ struct DeckDecodingTests { @Test("Marketplace-Forks werden erkannt") func recognizesMarketplaceFork() throws { - let json = """ + let json = Data(""" { "id": "deck_456", "user_id": "user_123", @@ -58,7 +58,7 @@ struct DeckDecodingTests { "created_at": "2026-05-01T00:00:00.000Z", "updated_at": "2026-05-01T00:00:00.000Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -71,7 +71,7 @@ struct DeckDecodingTests { @Test("DeckListResponse-Wrapper") func decodesListResponse() throws { - let json = """ + let json = Data(""" { "decks": [ { @@ -93,7 +93,7 @@ struct DeckDecodingTests { ], "total": 1 } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional diff --git a/Tests/UnitTests/MarketplaceDecodingTests.swift b/Tests/UnitTests/MarketplaceDecodingTests.swift index c86904c..c5aad0f 100644 --- a/Tests/UnitTests/MarketplaceDecodingTests.swift +++ b/Tests/UnitTests/MarketplaceDecodingTests.swift @@ -12,7 +12,7 @@ struct MarketplaceDecodingTests { @Test("PublicDeckEntry aus Browse-Response") func decodesPublicDeckEntry() throws { - let json = """ + let json = Data(""" { "slug": "geografie-welt-top30", "title": "Geografie Welt Top 30", @@ -34,7 +34,7 @@ struct MarketplaceDecodingTests { "pseudonym": false } } - """.data(using: .utf8)! + """.utf8) let entry = try decoder().decode(PublicDeckEntry.self, from: json) #expect(entry.slug == "geografie-welt-top30") @@ -46,12 +46,12 @@ struct MarketplaceDecodingTests { @Test("ExploreResponse mit featured + trending") func decodesExploreResponse() throws { - let json = """ + let json = Data(""" { "featured": [], "trending": [] } - """.data(using: .utf8)! + """.utf8) let res = try decoder().decode(ExploreResponse.self, from: json) #expect(res.featured.isEmpty) #expect(res.trending.isEmpty) @@ -59,7 +59,7 @@ struct MarketplaceDecodingTests { @Test("PublicDeckDetail mit camelCase 'latest_version'") func decodesPublicDeckDetail() throws { - let json = """ + let json = Data(""" { "deck": { "id": "deck_1", @@ -88,7 +88,7 @@ struct MarketplaceDecodingTests { }, "owner": null } - """.data(using: .utf8)! + """.utf8) let detail = try decoder().decode(PublicDeckDetail.self, from: json) #expect(detail.deck.slug == "english-a2") @@ -99,14 +99,14 @@ struct MarketplaceDecodingTests { @Test("SubscribeResponse mit private_deck_id") func decodesSubscribeResponse() throws { - let json = """ + let json = Data(""" { "subscribed": true, "deck_slug": "english-a2", "current_version_id": "v_1", "private_deck_id": "private_deck_xyz" } - """.data(using: .utf8)! + """.utf8) let res = try decoder().decode(SubscribeResponse.self, from: json) #expect(res.subscribed == true) #expect(res.privateDeckId == "private_deck_xyz") diff --git a/Tests/UnitTests/MaskRegionsTests.swift b/Tests/UnitTests/MaskRegionsTests.swift index d11fb9e..2eb8069 100644 --- a/Tests/UnitTests/MaskRegionsTests.swift +++ b/Tests/UnitTests/MaskRegionsTests.swift @@ -44,7 +44,7 @@ struct MaskRegionsTests { func encodeRoundtrip() { let original = [ MaskRegion(id: "m1", x: 0.1, y: 0.2, w: 0.3, h: 0.4, label: "test"), - MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil), + MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil) ] let encoded = MaskRegions.encode(original) let parsed = MaskRegions.parse(encoded) diff --git a/Tests/UnitTests/MutationEncodingTests.swift b/Tests/UnitTests/MutationEncodingTests.swift index 1336ffe..29dddbe 100644 --- a/Tests/UnitTests/MutationEncodingTests.swift +++ b/Tests/UnitTests/MutationEncodingTests.swift @@ -4,9 +4,16 @@ import Testing @Suite("Mutation Body Encoding") struct MutationEncodingTests { - private func encode(_ value: T) throws -> [String: Any] { + private func encode(_ value: some Encodable) throws -> [String: Any] { let data = try JSONEncoder().encode(value) - return try JSONSerialization.jsonObject(with: data) as! [String: Any] + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw EncodeError.notADictionary + } + return dict + } + + private enum EncodeError: Error { + case notADictionary } @Test("DeckCreateBody nutzt snake_case und lässt nil weg") diff --git a/Tests/UnitTests/ReviewDecodingTests.swift b/Tests/UnitTests/ReviewDecodingTests.swift index 1db436a..71c35f3 100644 --- a/Tests/UnitTests/ReviewDecodingTests.swift +++ b/Tests/UnitTests/ReviewDecodingTests.swift @@ -6,7 +6,7 @@ import Testing struct ReviewDecodingTests { @Test("Review-Wire-Format decodet vollständig") func decodesReview() throws { - let json = """ + let json = Data(""" { "card_id": "card_1", "sub_index": 0, @@ -22,7 +22,7 @@ struct ReviewDecodingTests { "state": "review", "last_review": "2026-05-10T10:00:00.000Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -39,7 +39,7 @@ struct ReviewDecodingTests { func decodesDueReview() throws { // Achtung: Server liefert hier `deckId` camelCase im embedded card, // weil das aus Drizzle direkt rauskommt, nicht durch toCardDto. - let json = """ + let json = Data(""" { "card_id": "c1", "sub_index": 0, @@ -61,7 +61,7 @@ struct ReviewDecodingTests { "fields": {"front": "Was ist 1+1?", "back": "2"} } } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional