主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
+ pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存
注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
294 lines
10 KiB
Swift
294 lines
10 KiB
Swift
import SwiftUI
|
||
|
||
struct B4ProgressView: View {
|
||
var onComplete: () -> Void
|
||
|
||
@State private var step: Int = 1
|
||
@State private var pulse = false
|
||
@State private var glow = false
|
||
@State private var rotate: Double = 0
|
||
@State private var elapsed: Double = 0.2
|
||
|
||
private let lineLabels = [
|
||
String(appLoc: "正在本地识别第 1 / 3 页…"),
|
||
String(appLoc: "正在本地识别第 2 / 3 页…"),
|
||
String(appLoc: "正在本地识别第 3 / 3 页…"),
|
||
String(appLoc: "提取指标 · 共 28 项"),
|
||
String(appLoc: "生成整体摘要…"),
|
||
]
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
backgroundGradient.ignoresSafeArea()
|
||
|
||
VStack(spacing: 0) {
|
||
Spacer()
|
||
chip.padding(.bottom, 36)
|
||
|
||
Text("本地 AI · 正在解读")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.tracking(1)
|
||
.foregroundStyle(Color.white.opacity(0.95))
|
||
.padding(.bottom, 6)
|
||
|
||
Text("QWEN2.5-VL · ON-DEVICE · SME2")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.tracking(0.5)
|
||
.foregroundStyle(Color.white.opacity(0.55))
|
||
.padding(.bottom, 30)
|
||
|
||
lineList
|
||
.padding(.horizontal, 28)
|
||
|
||
speedBadge.padding(.top, 32)
|
||
Spacer()
|
||
|
||
Text("本地处理中 · 不会上传任何内容")
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.tracking(0.5)
|
||
.foregroundStyle(Color.white.opacity(0.45))
|
||
.padding(.bottom, 30)
|
||
}
|
||
.padding(.horizontal, 28)
|
||
}
|
||
.preferredColorScheme(.dark)
|
||
.onAppear { startAnimations() }
|
||
}
|
||
|
||
private var backgroundGradient: some View {
|
||
RadialGradient(
|
||
colors: [
|
||
Color(red: 0.22, green: 0.21, blue: 0.18),
|
||
Color(red: 0.13, green: 0.12, blue: 0.10),
|
||
Color(red: 0.08, green: 0.075, blue: 0.06),
|
||
],
|
||
center: .init(x: 0.5, y: 0.3),
|
||
startRadius: 60,
|
||
endRadius: 700
|
||
)
|
||
}
|
||
|
||
private var chip: some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0))
|
||
.frame(width: 176, height: 176)
|
||
.blur(radius: 30)
|
||
|
||
Circle()
|
||
.strokeBorder(Color.white.opacity(0.18),
|
||
style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||
.frame(width: 140, height: 140)
|
||
.rotationEffect(.degrees(rotate))
|
||
|
||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [Color(red: 0.36, green: 0.34, blue: 0.30),
|
||
Color(red: 0.22, green: 0.21, blue: 0.18)],
|
||
startPoint: .topLeading, endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)
|
||
)
|
||
.frame(width: 96, height: 96)
|
||
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12)
|
||
.overlay(ChipGlyph())
|
||
.overlay(alignment: .topTrailing) {
|
||
Circle()
|
||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40))
|
||
.frame(width: 6, height: 6)
|
||
.opacity(pulse ? 1 : 0.35)
|
||
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6)
|
||
.padding(10)
|
||
}
|
||
.scaleEffect(pulse ? 1.06 : 1.0)
|
||
.opacity(pulse ? 0.92 : 1.0)
|
||
}
|
||
}
|
||
|
||
private var lineList: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in
|
||
LineRow(
|
||
text: label,
|
||
done: step > idx + 1,
|
||
active: step == idx + 1,
|
||
isLast: idx == lineLabels.count - 1
|
||
)
|
||
.opacity(step >= idx + 1 ? 1 : 0)
|
||
.offset(y: step >= idx + 1 ? 0 : 6)
|
||
.animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private var speedBadge: some View {
|
||
Text(String(format: String(appLoc: "已处理 %.1fs · 比云端快 4.2×"), elapsed))
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.tracking(0.6)
|
||
.foregroundStyle(Color.white.opacity(0.75))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(Capsule().fill(Color.white.opacity(0.08)))
|
||
}
|
||
|
||
private func startAnimations() {
|
||
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
|
||
pulse.toggle()
|
||
}
|
||
withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
|
||
glow.toggle()
|
||
}
|
||
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
|
||
rotate = 360
|
||
}
|
||
|
||
Task {
|
||
for _ in 0..<lineLabels.count {
|
||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||
await MainActor.run {
|
||
withAnimation { step += 1 }
|
||
elapsed += 0.9
|
||
}
|
||
}
|
||
try? await Task.sleep(nanoseconds: 600_000_000)
|
||
await MainActor.run { onComplete() }
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct LineRow: View {
|
||
let text: String
|
||
let done: Bool
|
||
let active: Bool
|
||
let isLast: Bool
|
||
|
||
@State private var dotPulse = false
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(done
|
||
? Color(red: 0.95, green: 0.78, blue: 0.40)
|
||
: Color.white.opacity(0.12))
|
||
if done {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 8, weight: .bold))
|
||
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
|
||
}
|
||
}
|
||
.frame(width: 14, height: 14)
|
||
|
||
Text(text)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(done ? Color.white.opacity(0.95) : Color.white.opacity(0.45))
|
||
|
||
if active {
|
||
Spacer()
|
||
Text("···")
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundStyle(Color.white.opacity(dotPulse ? 0.9 : 0.4))
|
||
.onAppear {
|
||
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
||
dotPulse.toggle()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ChipGlyph: View {
|
||
var body: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1.4)
|
||
.frame(width: 28, height: 28)
|
||
|
||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40).opacity(0.35))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||
.strokeBorder(Color(red: 0.95, green: 0.78, blue: 0.40), lineWidth: 1)
|
||
)
|
||
.frame(width: 16, height: 16)
|
||
|
||
innerCross
|
||
outerPins
|
||
}
|
||
.frame(width: 56, height: 56)
|
||
}
|
||
|
||
private var innerCross: some View {
|
||
Canvas { ctx, size in
|
||
let amber = Color(red: 0.95, green: 0.78, blue: 0.40)
|
||
let stroke = GraphicsContext.Shading.color(amber)
|
||
let cx = size.width / 2
|
||
let cy = size.height / 2
|
||
|
||
let pairs: [(CGPoint, CGPoint)] = [
|
||
(CGPoint(x: cx, y: cy - 8), CGPoint(x: cx, y: cy - 4)),
|
||
(CGPoint(x: cx, y: cy + 4), CGPoint(x: cx, y: cy + 8)),
|
||
(CGPoint(x: cx - 8, y: cy), CGPoint(x: cx - 4, y: cy)),
|
||
(CGPoint(x: cx + 4, y: cy), CGPoint(x: cx + 8, y: cy)),
|
||
]
|
||
for (s, e) in pairs {
|
||
var p = Path()
|
||
p.move(to: s)
|
||
p.addLine(to: e)
|
||
ctx.stroke(p, with: stroke, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||
}
|
||
}
|
||
.frame(width: 56, height: 56)
|
||
}
|
||
|
||
private var outerPins: some View {
|
||
Canvas { ctx, size in
|
||
let pinColor = GraphicsContext.Shading.color(Color.white.opacity(0.45))
|
||
let cx = size.width / 2
|
||
let cy = size.height / 2
|
||
let halfChip: CGFloat = 14
|
||
let outsideStart: CGFloat = 20
|
||
let outsideEnd: CGFloat = 26
|
||
|
||
let positions: [CGFloat] = [-8, 0, 8]
|
||
|
||
for offset in positions {
|
||
// top
|
||
var p = Path()
|
||
p.move(to: CGPoint(x: cx + offset, y: cy - outsideEnd))
|
||
p.addLine(to: CGPoint(x: cx + offset, y: cy - halfChip))
|
||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||
|
||
// bottom
|
||
p = Path()
|
||
p.move(to: CGPoint(x: cx + offset, y: cy + halfChip))
|
||
p.addLine(to: CGPoint(x: cx + offset, y: cy + outsideEnd))
|
||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||
|
||
// left
|
||
p = Path()
|
||
p.move(to: CGPoint(x: cx - outsideEnd, y: cy + offset))
|
||
p.addLine(to: CGPoint(x: cx - halfChip, y: cy + offset))
|
||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||
|
||
// right
|
||
p = Path()
|
||
p.move(to: CGPoint(x: cx + halfChip, y: cy + offset))
|
||
p.addLine(to: CGPoint(x: cx + outsideStart + 2, y: cy + offset))
|
||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||
}
|
||
}
|
||
.frame(width: 56, height: 56)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
B4ProgressView(onComplete: {})
|
||
}
|