v1.0.0 (5) — polish : thème CLAIR par défaut, images en fondu + cache 512 Mo, cartes d accueil avec photos (carrousel aligné), squelettes de chargement, micro-interactions à la pression, animations de listes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 changed files +103 −45
modified
KA/App/KAApp.swift
+7 −1
@@ -6,9 +6,15 @@ import SwiftUI | ||
| 6 | 6 | @main |
| 7 | 7 | struct KAApp: App { |
| 8 | 8 | @AppStorage("ka.onboarded") private var onboarded = false |
| 9 | − @AppStorage("ka.appearance") private var appearance = "auto" | |
| 9 | + @AppStorage("ka.appearance") private var appearance = "clair" | |
| 10 | 10 | @StateObject private var favorites = FavoritesStore.shared |
| 11 | 11 | |
| 12 | + init() { | |
| 13 | + // cache réseau généreux : images et JSON fluides, consultation hors ligne | |
| 14 | + URLCache.shared = URLCache(memoryCapacity: 64 * 1024 * 1024, | |
| 15 | + diskCapacity: 512 * 1024 * 1024) | |
| 16 | + } | |
| 17 | + | |
| 12 | 18 | var body: some Scene { |
| 13 | 19 | WindowGroup { |
| 14 | 20 | Group { |
modified
KA/Design/Theme.swift
+65 −10
@@ -129,16 +129,7 @@ struct KAItemRow: View { | ||
| 129 | 129 | var body: some View { |
| 130 | 130 | HStack(alignment: .top, spacing: 12) { |
| 131 | 131 | if let img = item.imageURL { |
| 132 | − AsyncImage(url: img) { phase in | |
| 133 | − switch phase { | |
| 134 | − case .success(let image): | |
| 135 | − image.resizable().aspectRatio(contentMode: .fill) | |
| 136 | − default: | |
| 137 | − (universe?.accent ?? .gray).opacity(0.12) | |
| 138 | − .overlay(Image(systemName: universe?.symbol ?? "photo") | |
| 139 | − .foregroundStyle(universe?.accent ?? .gray)) | |
| 140 | − } | |
| 141 | − } | |
| 132 | + KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") | |
| 142 | 133 | .frame(width: 74, height: 74) |
| 143 | 134 | .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) |
| 144 | 135 | .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) |
@@ -183,6 +174,70 @@ struct KAItemRow: View { | ||
| 183 | 174 | } |
| 184 | 175 | } |
| 185 | 176 | |
| 177 | +// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé) | |
| 178 | + | |
| 179 | +struct KAImage: View { | |
| 180 | + let url: URL? | |
| 181 | + var accent: Color = .gray | |
| 182 | + var symbol: String = "photo" | |
| 183 | + | |
| 184 | + var body: some View { | |
| 185 | + AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in | |
| 186 | + switch phase { | |
| 187 | + case .success(let image): | |
| 188 | + image.resizable().aspectRatio(contentMode: .fill) | |
| 189 | + .transition(.opacity) | |
| 190 | + case .failure: | |
| 191 | + accent.opacity(0.1) | |
| 192 | + .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6))) | |
| 193 | + default: | |
| 194 | + accent.opacity(0.08) | |
| 195 | + .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35))) | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +// MARK: - Pression tactile (échelle + réactivité) | |
| 202 | + | |
| 203 | +struct KAPressStyle: ButtonStyle { | |
| 204 | + func makeBody(configuration: Configuration) -> some View { | |
| 205 | + configuration.label | |
| 206 | + .scaleEffect(configuration.isPressed ? 0.97 : 1) | |
| 207 | + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) | |
| 208 | + } | |
| 209 | +} | |
| 210 | + | |
| 211 | +// MARK: - Squelette de chargement (liste) | |
| 212 | + | |
| 213 | +struct KASkeletonRow: View { | |
| 214 | + @Environment(\.colorScheme) private var scheme | |
| 215 | + var body: some View { | |
| 216 | + HStack(alignment: .top, spacing: 12) { | |
| 217 | + RoundedRectangle(cornerRadius: 10).fill(.quaternary).frame(width: 74, height: 74) | |
| 218 | + VStack(alignment: .leading, spacing: 8) { | |
| 219 | + RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14) | |
| 220 | + RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11) | |
| 221 | + RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11) | |
| 222 | + } | |
| 223 | + } | |
| 224 | + .padding(12).kaCard() | |
| 225 | + .redacted(reason: .placeholder) | |
| 226 | + .shimmering() | |
| 227 | + } | |
| 228 | +} | |
| 229 | + | |
| 230 | +struct Shimmer: ViewModifier { | |
| 231 | + @State private var on = false | |
| 232 | + func body(content: Content) -> some View { | |
| 233 | + content | |
| 234 | + .opacity(on ? 0.5 : 0.9) | |
| 235 | + .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on) | |
| 236 | + .onAppear { on = true } | |
| 237 | + } | |
| 238 | +} | |
| 239 | +extension View { func shimmering() -> some View { modifier(Shimmer()) } } | |
| 240 | + | |
| 186 | 241 | // MARK: - États |
| 187 | 242 | |
| 188 | 243 | struct KAEmptyState: View { |
modified
KA/Features/HomeView.swift
+23 −17
@@ -102,32 +102,38 @@ struct HomeView: View { | ||
| 102 | 102 | HStack(spacing: 12) { |
| 103 | 103 | ForEach(items.prefix(8)) { item in |
| 104 | 104 | NavigationLink(value: item) { |
| 105 | − VStack(alignment: .leading, spacing: 6) { | |
| 106 | − Text(item.title).font(.subheadline.weight(.semibold)) | |
| 107 | − .lineLimit(2, reservesSpace: true) | |
| 108 | − .multilineTextAlignment(.leading) | |
| 109 | − if let s = item.subtitle { | |
| 110 | − Text(s).font(.caption).foregroundStyle(.secondary).lineLimit(1) | |
| 111 | − } | |
| 112 | − Spacer(minLength: 0) | |
| 113 | − HStack { | |
| 114 | − if let p = item.priceLabel { | |
| 115 | − Text(p).font(.system(.caption, design: .rounded).weight(.bold)) | |
| 116 | − .foregroundStyle(u.accent) | |
| 105 | + VStack(alignment: .leading, spacing: 0) { | |
| 106 | + KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol) | |
| 107 | + .frame(width: 216, height: 118) | |
| 108 | + .clipped() | |
| 109 | + VStack(alignment: .leading, spacing: 4) { | |
| 110 | + Text(item.title).font(.subheadline.weight(.semibold)) | |
| 111 | + .lineLimit(2, reservesSpace: true) | |
| 112 | + .multilineTextAlignment(.leading) | |
| 113 | + HStack { | |
| 114 | + if let p = item.priceLabel { | |
| 115 | + Text(p).font(.system(.caption, design: .rounded).weight(.bold)) | |
| 116 | + .foregroundStyle(u.accent) | |
| 117 | + .lineLimit(1) | |
| 118 | + } | |
| 119 | + Spacer() | |
| 120 | + if let c = item.city { | |
| 121 | + Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) | |
| 122 | + } | |
| 117 | 123 | } |
| 118 | − Spacer() | |
| 119 | − if let c = item.city { Text(c).font(.caption2).foregroundStyle(.tertiary) } | |
| 120 | 124 | } |
| 125 | + .padding(10) | |
| 121 | 126 | } |
| 122 | − .padding(12) | |
| 123 | − .frame(width: 190, height: 110, alignment: .topLeading) | |
| 127 | + .frame(width: 216, alignment: .topLeading) | |
| 124 | 128 | .kaCard(accent: u.accent) |
| 125 | 129 | } |
| 126 | − .buttonStyle(.plain) | |
| 130 | + .buttonStyle(KAPressStyle()) | |
| 127 | 131 | } |
| 128 | 132 | } |
| 133 | + .scrollTargetLayout() | |
| 129 | 134 | .padding(.vertical, 4) |
| 130 | 135 | } |
| 136 | + .scrollTargetBehavior(.viewAligned) | |
| 131 | 137 | } |
| 132 | 138 | } |
| 133 | 139 | |
modified
KA/Features/ProfileView.swift
+1 −1
@@ -5,7 +5,7 @@ import SwiftUI | ||
| 5 | 5 | |
| 6 | 6 | struct ProfileView: View { |
| 7 | 7 | @AppStorage("ka.favUniverses") private var favUniversesRaw = "" |
| 8 | − @AppStorage("ka.appearance") private var appearance = "auto" | |
| 8 | + @AppStorage("ka.appearance") private var appearance = "clair" | |
| 9 | 9 | @Environment(\.colorScheme) private var scheme |
| 10 | 10 | @StateObject private var kaid = KAIDManager.shared |
| 11 | 11 | |
modified
KA/Features/SearchView.swift
+1 −1
@@ -118,7 +118,7 @@ struct SearchView: View { | ||
| 118 | 118 | } |
| 119 | 119 | ForEach(section.items.prefix(5)) { item in |
| 120 | 120 | NavigationLink(value: item) { KAItemRow(item: item) } |
| 121 | − .buttonStyle(.plain) | |
| 121 | + .buttonStyle(KAPressStyle()) | |
| 122 | 122 | } |
| 123 | 123 | NavigationLink(value: section.universe.id) { |
| 124 | 124 | Text("Tout voir dans \(section.universe.name) →") |
modified
KA/Features/UniversesView.swift
+5 −14
@@ -22,7 +22,7 @@ struct UniversesView: View { | ||
| 22 | 22 | NavigationLink(value: u.id) { |
| 23 | 23 | UniverseCard(universe: u, total: totals[u.id]) |
| 24 | 24 | } |
| 25 | − .buttonStyle(.plain) | |
| 25 | + .buttonStyle(KAPressStyle()) | |
| 26 | 26 | } |
| 27 | 27 | } |
| 28 | 28 | .padding(16) |
@@ -172,7 +172,7 @@ struct UniverseHomeView: View { | ||
| 172 | 172 | LazyVStack(spacing: 12) { |
| 173 | 173 | hero |
| 174 | 174 | if loading { |
| 175 | − ProgressView().padding(40) | |
| 175 | + ForEach(0..<6, id: \.self) { _ in KASkeletonRow() } | |
| 176 | 176 | } else if let e = errorText, items.isEmpty { |
| 177 | 177 | KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", |
| 178 | 178 | message: e + "\nTirez pour réessayer.") |
@@ -182,11 +182,12 @@ struct UniverseHomeView: View { | ||
| 182 | 182 | } else { |
| 183 | 183 | ForEach(items) { item in |
| 184 | 184 | NavigationLink(value: item) { KAItemRow(item: item) } |
| 185 | − .buttonStyle(.plain) | |
| 185 | + .buttonStyle(KAPressStyle()) | |
| 186 | 186 | } |
| 187 | 187 | } |
| 188 | 188 | } |
| 189 | 189 | .padding(16) |
| 190 | + .animation(.snappy(duration: 0.3), value: items) | |
| 190 | 191 | } |
| 191 | 192 | .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } |
| 192 | 193 | .searchable(text: $query, prompt: "Chercher dans \(universe.name)…") |
@@ -226,17 +227,7 @@ struct ItemDetailView: View { | ||
| 226 | 227 | ScrollView { |
| 227 | 228 | VStack(alignment: .leading, spacing: 16) { |
| 228 | 229 | if let img = item.imageURL { |
| 229 | − AsyncImage(url: img) { phase in | |
| 230 | − switch phase { | |
| 231 | − case .success(let image): | |
| 232 | − image.resizable().aspectRatio(contentMode: .fill) | |
| 233 | − case .failure: | |
| 234 | − EmptyView() | |
| 235 | − default: | |
| 236 | − (universe?.accent ?? .gray).opacity(0.1) | |
| 237 | − .overlay(ProgressView()) | |
| 238 | − } | |
| 239 | − } | |
| 230 | + KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") | |
| 240 | 231 | .frame(maxWidth: .infinity) |
| 241 | 232 | .frame(height: 230) |
| 242 | 233 | .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) |
added
docs/screenshots/05-accueil-photos.png
+0 −0
Binary file not shown.
modified
project.yml
+1 −1
@@ -10,7 +10,7 @@ settings: | ||
| 10 | 10 | base: |
| 11 | 11 | SWIFT_VERSION: "5.0" |
| 12 | 12 | MARKETING_VERSION: "1.0.0" |
| 13 | − CURRENT_PROJECT_VERSION: "4" | |
| 13 | + CURRENT_PROJECT_VERSION: "5" | |
| 14 | 14 | DEVELOPMENT_TEAM: "3YM54G49SN" |
| 15 | 15 | CODE_SIGN_STYLE: Automatic |
| 16 | 16 | GENERATE_INFOPLIST_FILE: true |
| 17 | 17 | |