feat(home): custom SVG zone icons for body-zone cards
All checks were successful
CI / Detect Changes (pull_request) Successful in 4s
CI / YouTube Worker (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped
PR → Build → devicectl Deploy → LGTM / Build & Deploy to iPhone (devicectl) (pull_request) Successful in 6m58s
PR → Build → devicectl Deploy → LGTM / Wait for LGTM comment (pull_request) Successful in 31s

Replace the hand-drawn Path silhouettes in ZoneHighlightIcon with
polished vector SVG artwork (upper/lower/full body figures).

- Add ZoneIconUpper/Lower/Full.imagesets to Assets.xcassets with
  preserves-vector-representation for crisp rendering at any size
- Rewrite ZoneHighlightIcon to load the SVG assets via Image with
  scaledToFit, preserving the zone: String API so ZoneCard is unchanged

Verified: xcodegen + xcodebuild (Swift 6 strict concurrency) succeeds;
rendered on iPhone 17 Pro simulator.
This commit is contained in:
Millian Lamiaux
2026-07-20 14:20:48 +02:00
parent 1ada238004
commit a843a2bd20
7 changed files with 156 additions and 120 deletions

View File

@@ -1,131 +1,35 @@
import SwiftUI
struct BodySilhouetteShape: Shape {
func path(in rect: CGRect) -> Path {
let w = rect.width
let h = rect.height
let mx = w * 0.5
var p = Path()
let headCY = h * 0.09
let headR = w * 0.12
p.addEllipse(in: CGRect(
x: mx - headR, y: headCY - headR,
width: headR * 2, height: headR * 2
))
let neckW = w * 0.06
let neckTop = headCY + headR
let shoulderY = h * 0.20
p.addRect(CGRect(
x: mx - neckW, y: neckTop,
width: neckW * 2, height: shoulderY - neckTop
))
let shoulderHW = w * 0.42
let armOuterBotY = h * 0.44
let armInnerBotY = h * 0.44
let armOuterX = w * 0.06
let armInnerX = w * 0.12
p.move(to: CGPoint(x: mx - neckW, y: shoulderY))
p.addLine(to: CGPoint(x: mx - shoulderHW, y: shoulderY))
p.addLine(to: CGPoint(x: mx - armOuterX, y: armOuterBotY))
p.addLine(to: CGPoint(x: mx - armInnerX, y: armInnerBotY))
p.closeSubpath()
p.move(to: CGPoint(x: mx + neckW, y: shoulderY))
p.addLine(to: CGPoint(x: mx + shoulderHW, y: shoulderY))
p.addLine(to: CGPoint(x: mx + armOuterX, y: armOuterBotY))
p.addLine(to: CGPoint(x: mx + armInnerX, y: armInnerBotY))
p.closeSubpath()
let torsoHW = w * 0.18
let waistY = h * 0.46
let waistHW = w * 0.15
p.move(to: CGPoint(x: mx - torsoHW, y: shoulderY))
p.addLine(to: CGPoint(x: mx + torsoHW, y: shoulderY))
p.addLine(to: CGPoint(x: mx + waistHW, y: waistY))
p.addLine(to: CGPoint(x: mx - waistHW, y: waistY))
p.closeSubpath()
let hipHW = w * 0.22
let hipY = h * 0.52
p.move(to: CGPoint(x: mx - waistHW, y: waistY))
p.addLine(to: CGPoint(x: mx + waistHW, y: waistY))
p.addLine(to: CGPoint(x: mx + hipHW, y: hipY))
p.addLine(to: CGPoint(x: mx - hipHW, y: hipY))
p.closeSubpath()
let gap = w * 0.02
let legBotY = h * 0.92
let footH = h * 0.05
let footExtra = w * 0.04
p.move(to: CGPoint(x: mx - hipHW, y: hipY))
p.addLine(to: CGPoint(x: mx - gap, y: hipY))
p.addLine(to: CGPoint(x: mx - gap, y: legBotY))
p.addLine(to: CGPoint(x: mx - hipHW - footExtra, y: legBotY + footH))
p.addLine(to: CGPoint(x: mx - hipHW, y: legBotY))
p.closeSubpath()
p.move(to: CGPoint(x: mx + gap, y: hipY))
p.addLine(to: CGPoint(x: mx + hipHW, y: hipY))
p.addLine(to: CGPoint(x: mx + hipHW, y: legBotY))
p.addLine(to: CGPoint(x: mx + hipHW + footExtra, y: legBotY + footH))
p.addLine(to: CGPoint(x: mx + gap, y: legBotY))
p.closeSubpath()
return p
}
}
/// Body-zone highlight icon backed by vector SVG assets in `Assets.xcassets`.
///
/// Each zone renders its own artwork:
/// - `upper-body` `ZoneIconUpper`
/// - `lower-body` `ZoneIconLower`
/// - `full-body` `ZoneIconFull`
///
/// The SVGs ship with their own baked-in colors, so the icon is **not**
/// tinted by `Theme.zoneGradient` it shows the artwork as authored.
/// To make icons auto-recolor per zone instead, switch the imagesets to
/// single-color SVGs and set `"template-rendering-intent": "template"`
/// in each `Contents.json`, then apply `.foregroundStyle(...)` here.
///
/// Drawn on a 56×80 frame with `scaledToFit()` so the SVG keeps its
/// native aspect ratio inside the card.
struct ZoneHighlightIcon: View {
let zone: String
private var waistFraction: CGFloat { 0.50 }
var body: some View {
let shape = BodySilhouetteShape()
ZStack {
shape
.fill(.white.opacity(0.12))
shape
.fill(zoneGradient)
.mask(zoneMask)
}
.frame(width: 56, height: 80)
Image(decorative: imageName, bundle: .main)
.resizable()
.scaledToFit()
.frame(width: 56, height: 80)
}
private var zoneGradient: LinearGradient {
switch zone {
case "upper-body":
LinearGradient(colors: [.orange, .red.opacity(0.8)], startPoint: .top, endPoint: .bottom)
case "lower-body":
LinearGradient(colors: [.blue, .purple.opacity(0.8)], startPoint: .top, endPoint: .bottom)
case "full-body":
LinearGradient(colors: [Theme.brand, .purple], startPoint: .top, endPoint: .bottom)
default:
LinearGradient(colors: [.gray, .secondary], startPoint: .top, endPoint: .bottom)
}
}
@ViewBuilder
private var zoneMask: some View {
switch zone {
case "upper-body":
Rectangle()
.frame(height: 80 * waistFraction)
.frame(maxHeight: .infinity, alignment: .top)
case "lower-body":
Rectangle()
.frame(height: 80 * waistFraction)
.frame(maxHeight: .infinity, alignment: .bottom)
case "full-body":
Rectangle()
default:
Rectangle()
private var imageName: String {
switch zone.lowercased() {
case "upper-body", "upper": return "ZoneIconUpper"
case "lower-body", "lower": return "ZoneIconLower"
default: return "ZoneIconFull"
}
}
}