410 lines
12 KiB
Swift
410 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
enum AppTheme {
|
|
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
|
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
|
|
static let border = Color.black.opacity(0.08)
|
|
static let shadow = Color.black.opacity(0.05)
|
|
static let cardFill = Color.white.opacity(0.96)
|
|
static let mutedFill = Color(red: 0.972, green: 0.976, blue: 0.970)
|
|
}
|
|
|
|
enum AppLayout {
|
|
static let compactHorizontalPadding: CGFloat = 16
|
|
static let regularHorizontalPadding: CGFloat = 28
|
|
static let compactVerticalPadding: CGFloat = 18
|
|
static let regularVerticalPadding: CGFloat = 28
|
|
static let compactContentWidth: CGFloat = 720
|
|
static let regularContentWidth: CGFloat = 920
|
|
static let cardRadius: CGFloat = 24
|
|
static let largeCardRadius: CGFloat = 30
|
|
static let compactSectionPadding: CGFloat = 18
|
|
static let regularSectionPadding: CGFloat = 24
|
|
static let compactSectionSpacing: CGFloat = 18
|
|
static let regularSectionSpacing: CGFloat = 24
|
|
static let compactBottomDockPadding: CGFloat = 120
|
|
static let regularBottomPadding: CGFloat = 56
|
|
|
|
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
|
|
}
|
|
|
|
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactVerticalPadding : regularVerticalPadding
|
|
}
|
|
|
|
static func contentWidth(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactContentWidth : regularContentWidth
|
|
}
|
|
|
|
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactSectionPadding : regularSectionPadding
|
|
}
|
|
|
|
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactSectionSpacing : regularSectionSpacing
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
|
|
background(
|
|
fill,
|
|
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
|
|
}
|
|
}
|
|
|
|
struct AppBackground: View {
|
|
var body: some View {
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.975, green: 0.978, blue: 0.972),
|
|
Color.white
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.overlay(alignment: .top) {
|
|
Rectangle()
|
|
.fill(Color.black.opacity(0.02))
|
|
.frame(height: 160)
|
|
.blur(radius: 60)
|
|
.offset(y: -90)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
|
|
struct AppScrollScreen<Content: View>: View {
|
|
let compactLayout: Bool
|
|
var bottomPadding: CGFloat? = nil
|
|
let content: () -> Content
|
|
|
|
init(
|
|
compactLayout: Bool,
|
|
bottomPadding: CGFloat? = nil,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.compactLayout = compactLayout
|
|
self.bottomPadding = bottomPadding
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
|
content()
|
|
}
|
|
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
|
|
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
|
|
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
|
|
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
|
|
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
}
|
|
}
|
|
|
|
struct AppPanel<Content: View>: View {
|
|
let compactLayout: Bool
|
|
let radius: CGFloat
|
|
let content: () -> Content
|
|
|
|
init(
|
|
compactLayout: Bool,
|
|
radius: CGFloat = AppLayout.cardRadius,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.compactLayout = compactLayout
|
|
self.radius = radius
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
content()
|
|
}
|
|
.padding(AppLayout.sectionPadding(for: compactLayout))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.appSurface(radius: radius)
|
|
}
|
|
}
|
|
|
|
struct AppBadge: View {
|
|
let title: String
|
|
var tone: Color = AppTheme.accent
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(tone)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(tone.opacity(0.10), in: Capsule())
|
|
}
|
|
}
|
|
|
|
struct AppSectionCard<Content: View>: View {
|
|
let title: String
|
|
var subtitle: String? = nil
|
|
let compactLayout: Bool
|
|
let content: () -> Content
|
|
|
|
init(
|
|
title: String,
|
|
subtitle: String? = nil,
|
|
compactLayout: Bool,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.compactLayout = compactLayout
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
AppPanel(compactLayout: compactLayout) {
|
|
AppSectionTitle(title: title, subtitle: subtitle)
|
|
content()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AppSectionTitle: View {
|
|
let title: String
|
|
var subtitle: String? = nil
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.title3.weight(.semibold))
|
|
|
|
if let subtitle, !subtitle.isEmpty {
|
|
Text(subtitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AppNotice: View {
|
|
let message: String
|
|
var tone: Color = AppTheme.accent
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.footnote.weight(.bold))
|
|
.foregroundStyle(tone)
|
|
Text(message)
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(tone.opacity(0.08), in: Capsule())
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct AppStatusTag: View {
|
|
let title: String
|
|
var tone: Color = AppTheme.accent
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(tone.opacity(0.12), in: Capsule())
|
|
.foregroundStyle(tone)
|
|
}
|
|
}
|
|
|
|
struct AppKeyValue: View {
|
|
let label: String
|
|
let value: String
|
|
var monospaced: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label.uppercased())
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
.font(monospaced ? .subheadline.monospaced() : .subheadline.weight(.semibold))
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
struct AppMetric: View {
|
|
let title: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title.uppercased())
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
.font(.title3.weight(.bold))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
struct AppTextSurface: View {
|
|
let text: String
|
|
var monospaced: Bool = false
|
|
|
|
var body: some View {
|
|
content
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(16)
|
|
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
#if os(watchOS)
|
|
Text(text)
|
|
.font(monospaced ? .body.monospaced() : .body)
|
|
#else
|
|
Text(text)
|
|
.font(monospaced ? .body.monospaced() : .body)
|
|
.textSelection(.enabled)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
struct AppTextEditorField: View {
|
|
@Binding var text: String
|
|
var minHeight: CGFloat = 120
|
|
var monospaced: Bool = true
|
|
|
|
var body: some View {
|
|
editor
|
|
.frame(minHeight: minHeight)
|
|
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var editor: some View {
|
|
#if os(watchOS)
|
|
Text(text)
|
|
.font(monospaced ? .body.monospaced() : .body)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(14)
|
|
#else
|
|
TextEditor(text: $text)
|
|
.font(monospaced ? .body.monospaced() : .body)
|
|
.scrollContentBackground(.hidden)
|
|
.autocorrectionDisabled()
|
|
.padding(14)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
struct AppActionRow: View {
|
|
let title: String
|
|
var subtitle: String? = nil
|
|
let systemImage: String
|
|
var tone: Color = AppTheme.accent
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: systemImage)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(tone)
|
|
.frame(width: 28, height: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
if let subtitle, !subtitle.isEmpty {
|
|
Text(subtitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Image(systemName: "arrow.right")
|
|
.font(.footnote.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
struct AppActionTile: View {
|
|
let title: String
|
|
let systemImage: String
|
|
var tone: Color = AppTheme.accent
|
|
var isBusy: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .center) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(tone.opacity(0.10))
|
|
.frame(width: 38, height: 38)
|
|
|
|
if isBusy {
|
|
ProgressView()
|
|
.tint(tone)
|
|
} else {
|
|
Image(systemName: systemImage)
|
|
.font(.headline.weight(.semibold))
|
|
.foregroundStyle(tone)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Image(systemName: "arrow.up.right")
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Text(title)
|
|
.font(.headline)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
|
|
.appSurface(radius: 22)
|
|
}
|
|
}
|