diff --git a/Symi/Resources/Localizable.xcstrings b/Symi/Resources/Localizable.xcstrings index 0b6de11..446f8b7 100644 --- a/Symi/Resources/Localizable.xcstrings +++ b/Symi/Resources/Localizable.xcstrings @@ -1199,6 +1199,9 @@ } } } + }, + "Eingenommen" : { + }, "Einschränkung: %@" : { "localizations" : { @@ -2283,6 +2286,9 @@ }, "Mit leerem Store starten" : { + }, + "Mögliche Auslöser" : { + }, "Monatskalender %@" : { diff --git a/Symi/Sources/Features/History/EpisodeDetailView.swift b/Symi/Sources/Features/History/EpisodeDetailView.swift index 82cfabc..67a3cf1 100644 --- a/Symi/Sources/Features/History/EpisodeDetailView.swift +++ b/Symi/Sources/Features/History/EpisodeDetailView.swift @@ -29,39 +29,49 @@ struct EpisodeDetailView: View { } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: SymiSpacing.xl) { - EntryDetailHeader( - title: headerTitle, - onBack: { dismiss() }, - onEdit: { isEditing = true } - ) + VStack(spacing: SymiSpacing.zero) { + EntryDetailHeader( + title: headerTitle, + onBack: { dismiss() }, + onEdit: { isEditing = true } + ) + .padding(.horizontal, SymiSpacing.xxl) + .padding(.top, SymiSpacing.xl) - if let episode { - EntryDetailHeroCard(episode: episode) + ScrollView { + VStack(alignment: .leading, spacing: SymiSpacing.xxxl) { + if let episode { + EntryDetailHeroCard(episode: episode) - if !detailRows.isEmpty { - EntryDetailSectionCard(rows: detailRows) - } + EntryDetailContextCard(episode: episode) + + if episode.hasVisibleTriggers { + EntryDetailTriggerSection(triggers: episode.triggers) + } - EntryDetailActions( - onEdit: { isEditing = true }, - onDelete: { isShowingDeleteConfirmation = true } - ) - } else { - EntryDetailLoadingState(isLoading: isLoading) + EntryDetailMedicationCard(episode: episode) + + EntryDetailDeleteAction(onDelete: { isShowingDeleteConfirmation = true }) + } else { + EntryDetailLoadingState(isLoading: isLoading) + } } + .padding(.horizontal, SymiSpacing.xxl) + .padding(.top, SymiSpacing.xxxl) + .padding(.bottom, SymiSpacing.entryDetailBottomPadding) } - .padding(.horizontal, SymiSpacing.xxl) - .padding(.top, SymiSpacing.xl) - .padding(.bottom, SymiSpacing.xxxl) } - .background(SymiColors.warmBackground.color.ignoresSafeArea()) + .background(ColorToken.Surface.appBackground.ignoresSafeArea()) + .overlay(alignment: .top) { + ColorToken.Surface.topFade + .frame(height: SymiSize.entryDetailTopFadeHeight) + .allowsHitTesting(false) + } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) - .tint(SymiColors.primaryPetrol.color) + .tint(ColorToken.Brand.primary) .task { await reload() } @@ -94,44 +104,6 @@ struct EpisodeDetailView: View { return episode.startedAt.formatted(.dateTime.weekday(.wide).day().month(.wide)) } - private var detailRows: [EntryDetailRowModel] { - guard let episode else { - return [] - } - - var rows: [EntryDetailRowModel] = [ - EntryDetailRowModel( - title: "Zeitpunkt", - value: JournalEntryContext.timeOfDay(for: episode.startedAt) - ) - ] - - if !episode.painLocation.trimmed.isEmpty { - rows.insert( - EntryDetailRowModel(title: "Ort", value: episode.painLocation.trimmed), - at: 0 - ) - } - - if let medicationDetail = JournalEntryContext.medicationDetail(for: episode) { - rows.append(EntryDetailRowModel(title: "Medikation", value: medicationDetail)) - } - - if !episode.painCharacter.trimmed.isEmpty { - rows.append(EntryDetailRowModel(title: "Charakter", value: episode.painCharacter.trimmed)) - } - - if !episode.functionalImpact.trimmed.isEmpty { - rows.append(EntryDetailRowModel(title: "Verlauf", value: episode.functionalImpact.trimmed)) - } - - if !episode.notes.trimmed.isEmpty { - rows.append(EntryDetailRowModel(title: "Notiz", value: episode.notes.trimmed, isMultiline: true)) - } - - return rows - } - private func handleSavedEpisode() { isEditing = false Task { @@ -169,130 +141,476 @@ private struct EntryDetailHeader: View { Button(action: onBack) { Image(systemName: "chevron.left") .font(.headline.weight(.semibold)) - .foregroundStyle(SymiColors.textPrimary.color) + .foregroundStyle(ColorToken.Text.onSurface) .frame(width: SymiSize.minInteractiveHeight, height: SymiSize.minInteractiveHeight) - .background(SymiColors.onAccent.color, in: Circle()) + .background(ColorToken.Surface.headerControlBackground, in: Circle()) } .buttonStyle(.plain) .accessibilityLabel("Zurück") Text(title) .font(.system(.headline, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textPrimary.color) + .foregroundStyle(ColorToken.Text.onSurface) .lineLimit(1) .minimumScaleFactor(SymiTypography.compactScaleFactor) - - Spacer(minLength: SymiSpacing.md) + .frame(maxWidth: .infinity, alignment: .center) Button("Bearbeiten", action: onEdit) .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.primaryPetrol.color) - .padding(.horizontal, SymiSpacing.md) + .foregroundStyle(ColorToken.Brand.primary) .frame(minHeight: SymiSize.minInteractiveHeight) - .background(SymiColors.onAccent.color, in: Capsule()) .buttonStyle(.plain) } } } +private enum EntryDetailSurface { + static let cornerRadius: CGFloat = 28 + static let shadowColor = ColorToken.Shadow.card + static let shadowRadius: CGFloat = 24 + static let shadowYOffset: CGFloat = 10 + static let cardFill = ColorToken.Surface.primary + static let highlight = ColorToken.Surface.cardHighlight + static let iconFill = ColorToken.Surface.iconBackground +} + private struct EntryDetailHeroCard: View { let episode: EpisodeRecord - private var intensityColor: Color { - JournalEntryContext.intensityColor(for: episode.intensity) + private var painLevel: PainLevel { + PainLevel(intensity: episode.intensity) + } + + private var painToken: PainToken { + ColorToken.Pain.token(for: painLevel) } var body: some View { - VStack(alignment: .leading, spacing: SymiSpacing.xl) { - VStack(alignment: .leading, spacing: SymiSpacing.xs) { - Text(JournalEntryContext.intensityLabel(for: episode.intensity)) - .font(.system(.largeTitle, design: .rounded).weight(.bold)) - .foregroundStyle(SymiColors.textPrimary.color) - - Text("\(episode.intensity)/10") - .font(.system(.title2, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textSecondary.color) - .monospacedDigit() + VStack(alignment: .leading, spacing: SymiSpacing.entryDetailHeroSpacing) { + HStack(alignment: .top, spacing: SymiSpacing.lg) { + VStack(alignment: .leading, spacing: SymiSpacing.compact) { + Text("Intensität") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ColorToken.Text.label) + + VStack(alignment: .leading, spacing: SymiSpacing.zero) { + Text(JournalEntryContext.intensityLabel(for: episode.intensity)) + .font(SymiTypography.entryDetailIntensityTitle) + .foregroundStyle(painToken.emphasizedText) + .minimumScaleFactor(SymiTypography.compactScaleFactor) + + Text("\(episode.intensity)/10") + .font(.system(.title2, design: .rounded).weight(.semibold)) + .foregroundStyle(ColorToken.Text.label) + .monospacedDigit() + } + } + + Spacer(minLength: SymiSpacing.md) + + EntryDetailFaceBadge(painLevel: painLevel, painToken: painToken) } - ProgressView(value: Double(episode.intensity), total: 10) - .tint(intensityColor) + EntryDetailProgressBar(value: Double(episode.intensity) / 10, painToken: painToken) .accessibilityLabel("Intensität") .accessibilityValue("\(episode.intensity) von 10") + + Text(intensityDescription) + .font(.system(.body, design: .rounded).weight(.medium)) + .foregroundStyle(painToken.descriptionText) + .fixedSize(horizontal: false, vertical: true) } - .padding(SymiSpacing.xxl) + .padding(SymiSpacing.entryDetailHeroPadding) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous)) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous) - .stroke(Color.primary.opacity(SymiOpacity.journalBorder), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) ) .shadow( - color: Color.primary.opacity(SymiOpacity.journalShadow), - radius: SymiShadow.journalCardRadius, + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, x: SymiShadow.journalCardXOffset, - y: SymiShadow.journalCardYOffset + y: EntryDetailSurface.shadowYOffset + ) + } + + private var intensityDescription: String { + switch episode.intensity { + case 1 ... 3: + "Die Schmerzen waren leicht und gut im Alltag einzuordnen." + case 4 ... 6: + "Die Schmerzen waren spürbar, aber noch gut auszuhalten." + case 7 ... 10: + "Die Schmerzen waren deutlich und haben viel Aufmerksamkeit gebraucht." + default: + "Die Intensität wurde für diesen Eintrag nicht bewertet." + } + } +} + +private struct EntryDetailFaceBadge: View { + let painLevel: PainLevel + let painToken: PainToken + + var body: some View { + ZStack { + Circle() + .fill(painToken.faceBackground) + + CalmFaceIcon(painLevel: painLevel) + .stroke( + painToken.icon, + style: StrokeStyle(lineWidth: SymiStroke.entryDetailFaceIcon, lineCap: .round, lineJoin: .round) + ) + .frame(width: SymiSize.entryDetailFaceIcon, height: SymiSize.entryDetailFaceIcon) + } + .frame(width: SymiSize.entryDetailFaceBadge, height: SymiSize.entryDetailFaceBadge) + .accessibilityHidden(true) + } +} + +private struct CalmFaceIcon: Shape { + let painLevel: PainLevel + + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.width + let height = rect.height + + path.addEllipse(in: CGRect(x: width * 0.06, y: height * 0.04, width: width * 0.88, height: height * 0.90)) + + path.move(to: CGPoint(x: width * 0.34, y: leftEyeStartY * height)) + path.addLine(to: CGPoint(x: width * 0.34, y: leftEyeEndY * height)) + + path.move(to: CGPoint(x: width * 0.66, y: rightEyeStartY * height)) + path.addLine(to: CGPoint(x: width * 0.66, y: rightEyeEndY * height)) + + path.move(to: CGPoint(x: width * 0.36, y: mouthY * height)) + path.addQuadCurve( + to: CGPoint(x: width * 0.64, y: mouthY * height), + control: CGPoint(x: width * 0.50, y: mouthControlY * height) ) + + return path + } + + private var leftEyeStartY: CGFloat { + switch painLevel { + case .high: + 0.38 + case .none, .low, .medium: + 0.40 + } + } + + private var leftEyeEndY: CGFloat { + switch painLevel { + case .high: + 0.42 + case .none, .low, .medium: + 0.42 + } + } + + private var rightEyeStartY: CGFloat { + switch painLevel { + case .high: + 0.38 + case .none, .low, .medium: + 0.40 + } + } + + private var rightEyeEndY: CGFloat { + switch painLevel { + case .high: + 0.42 + case .none, .low, .medium: + 0.42 + } + } + + private var mouthY: CGFloat { + switch painLevel { + case .none: + 0.63 + case .low: + 0.64 + case .medium: + 0.63 + case .high: + 0.64 + } + } + + private var mouthControlY: CGFloat { + switch painLevel { + case .none: + 0.68 + case .low: + 0.62 + case .medium: + 0.67 + case .high: + 0.59 + } } } -private struct EntryDetailSectionCard: View { - let rows: [EntryDetailRowModel] +private struct EntryDetailProgressBar: View { + let value: Double + let painToken: PainToken var body: some View { - VStack(alignment: .leading, spacing: SymiSpacing.lg) { + GeometryReader { proxy in + let clampedValue = min(max(value, 0), 1) + + ZStack(alignment: .leading) { + Capsule() + .fill(ColorToken.Surface.progressTrack) + + Capsule() + .fill(painToken.progressGradient) + .overlay(alignment: .top) { + Capsule() + .fill(ColorToken.Surface.progressHighlight) + .frame(height: SymiSize.entryDetailProgressHighlightHeight) + .padding(.horizontal, SymiSize.accessibilityMarker) + } + .frame(width: proxy.size.width * clampedValue) + } + } + .frame(height: SymiSize.entryDetailProgressBarHeight) + } +} + +private struct EntryDetailContextCard: View { + let episode: EpisodeRecord + + private var painLevel: PainLevel { + PainLevel(intensity: episode.intensity) + } + + private var rows: [EntryDetailContextRowModel] { + [ + EntryDetailContextRowModel(systemImage: "clock", title: JournalEntryContext.timeOfDay(for: episode.startedAt)), + EntryDetailContextRowModel( + systemImage: "brain.head.profile", + title: painLocationText, + hierarchy: .secondary, + category: .pain(painLevel) + ), + EntryDetailContextRowModel(systemImage: "note.text", title: noteText, hierarchy: .tertiary, category: .note) + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: SymiSpacing.entryDetailContextRowSpacing) { ForEach(rows) { row in - EntryDetailRow(row: row) + EntryDetailContextRow(row: row) } } - .padding(SymiSpacing.xxl) + .padding(.horizontal, SymiSpacing.entryDetailContextHorizontalPadding) + .padding(.vertical, SymiSpacing.entryDetailContextVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous)) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous) - .stroke(Color.primary.opacity(SymiOpacity.journalBorder), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) ) + .shadow( + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, + x: SymiShadow.cardXOffset, + y: EntryDetailSurface.shadowYOffset + ) + } + + private var painLocationText: String { + episode.painLocation.trimmed.isEmpty ? "Kein Ort dokumentiert" : episode.painLocation.trimmed + } + + private var noteText: String { + episode.notes.trimmed.isEmpty ? "Keine Notiz dokumentiert." : episode.notes.trimmed } } -private struct EntryDetailRow: View { - let row: EntryDetailRowModel +private struct EntryDetailContextRow: View { + let row: EntryDetailContextRowModel var body: some View { - VStack(alignment: .leading, spacing: SymiSpacing.xxs) { - Text(row.title) - .font(.system(.caption, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textSecondary.color) + HStack(alignment: .center, spacing: SymiSpacing.lg) { + EntryDetailContextIcon(systemImage: row.systemImage, category: row.category) - Text(row.value) - .font(.system(.body, design: .rounded)) - .foregroundStyle(SymiColors.textPrimary.color) - .lineLimit(row.isMultiline ? nil : 2) + Text(row.title) + .font(row.hierarchy.font) + .foregroundStyle(row.hierarchy.color) + .lineLimit(row.hierarchy.lineLimit) .fixedSize(horizontal: false, vertical: true) } + .padding(.vertical, SymiSpacing.entryDetailContextRowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } } -private struct EntryDetailActions: View { - let onEdit: () -> Void - let onDelete: () -> Void +private struct EntryDetailContextIcon: View { + let systemImage: String + let category: EntryDetailContextCategory var body: some View { - VStack(spacing: SymiSpacing.sm) { - Button("Bearbeiten", action: onEdit) - .font(.system(.body, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.onAccent.color) - .frame(maxWidth: .infinity, minHeight: SymiSize.primaryButtonHeight) - .background(SymiColors.primaryPetrol.color, in: Capsule()) - - Button("Löschen", role: .destructive, action: onDelete) - .font(.system(.body, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.intensityStrong.color) - .frame(maxWidth: .infinity, minHeight: SymiSize.primaryButtonHeight) + Image(systemName: systemImage) + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium, design: .rounded)) + .foregroundStyle(category.iconColor) + .frame(width: SymiSize.entryDetailContextIcon, height: SymiSize.entryDetailContextIcon) + .offset(iconOffset) + .background(EntryDetailSurface.iconFill, in: Circle()) + } + + private var iconSize: CGFloat { + switch systemImage { + case "brain.head.profile": + 15.5 + case "note.text": + 15.8 + default: + 16 + } + } + + private var iconOffset: CGSize { + switch systemImage { + case "brain.head.profile": + CGSize(width: -0.5, height: 0.2) + case "note.text": + CGSize(width: 0, height: 0.4) + default: + .zero } - .buttonStyle(.plain) + } +} + +private struct EntryDetailTriggerSection: View { + let triggers: [String] + + var body: some View { + VStack(alignment: .leading, spacing: SymiSpacing.entryDetailTriggerSectionSpacing) { + Text("Mögliche Auslöser") + .font(.system(.headline, design: .rounded).weight(.semibold)) + .foregroundStyle(ColorToken.Text.onSurface) + + LazyVGrid(columns: columns, alignment: chipAlignment, spacing: SymiSpacing.entryDetailTriggerGridSpacing) { + ForEach(visibleTriggers, id: \.self) { trigger in + EntryDetailTriggerChip(title: trigger) + } + } + .padding(SymiSpacing.entryDetailTriggerCardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) + ) + .shadow( + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, + x: SymiShadow.cardXOffset, + y: EntryDetailSurface.shadowYOffset + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var visibleTriggers: [String] { + triggers.map(\.trimmed).filter { !$0.isEmpty } + } + + private var chipAlignment: HorizontalAlignment { + visibleTriggers.count == 1 ? .center : .leading + } + + private var columns: [GridItem] { + [ + GridItem( + .adaptive(minimum: SymiSize.entryDetailTriggerGridMinWidth), + spacing: SymiSpacing.entryDetailTriggerGridColumnSpacing, + alignment: .leading + ) + ] + } +} + +private struct EntryDetailTriggerChip: View { + let title: String + + var body: some View { + Text(title) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ColorToken.Trigger.foreground) + .lineLimit(1) + .minimumScaleFactor(SymiTypography.tightChipScaleFactor) + .padding(.horizontal, SymiSpacing.entryDetailTriggerChipHorizontalPadding) + .padding(.vertical, SymiSpacing.entryDetailTriggerChipVerticalPadding) + .background(ColorToken.Trigger.background, in: Capsule()) + } +} + +private struct EntryDetailMedicationCard: View { + let episode: EpisodeRecord + + var body: some View { + VStack(alignment: .leading, spacing: SymiSpacing.xs) { + Text("Eingenommen") + .font(.system(.headline, design: .rounded).weight(.semibold)) + .foregroundStyle(ColorToken.Medication.foreground) + + Text(medicationText) + .font(.system(.body, design: .rounded).weight(.medium)) + .foregroundStyle(ColorToken.Text.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(SymiSpacing.entryDetailMedicationCardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) + ) + .shadow( + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, + x: SymiShadow.cardXOffset, + y: EntryDetailSurface.shadowYOffset + ) + } + + private var medicationText: String { + JournalEntryContext.medicationDetail(for: episode) ?? "Keine Medikation" + } +} + +private struct EntryDetailDeleteAction: View { + let onDelete: () -> Void + + var body: some View { + Button("Löschen", role: .destructive, action: onDelete) + .font(.system(.body, design: .rounded).weight(.semibold)) + .foregroundStyle(ColorToken.Text.destructive) + .frame(maxWidth: .infinity, minHeight: SymiSize.entryDetailDeleteHeight) + .buttonStyle(EntryDetailDestructiveTextButtonStyle()) + .padding(.top, SymiSpacing.micro) + .padding(.bottom, SymiSpacing.entryDetailDeleteBottomPadding) + } +} + +private struct EntryDetailDestructiveTextButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? SymiOpacity.entryDetailDeletePressed : SymiOpacity.opaque) + .scaleEffect(configuration.isPressed ? SymiOpacity.entryDetailDeleteScale : SymiOpacity.opaque) + .animation(.easeOut(duration: SymiAnimation.quickDuration), value: configuration.isPressed) } } @@ -311,11 +629,72 @@ private struct EntryDetailLoadingState: View { } } -private struct EntryDetailRowModel: Identifiable { +private struct EntryDetailContextRowModel: Identifiable { let id = UUID() + let systemImage: String let title: String - let value: String - var isMultiline = false + var hierarchy: EntryDetailContextHierarchy = .primary + var category: EntryDetailContextCategory = .neutral +} + +private enum EntryDetailContextHierarchy { + case primary + case secondary + case tertiary + + var font: Font { + switch self { + case .primary: + .system(.title3, design: .rounded).weight(.semibold) + case .secondary: + .system(.body, design: .rounded).weight(.medium) + case .tertiary: + .system(.subheadline, design: .rounded).weight(.medium) + } + } + + var color: Color { + switch self { + case .primary: + ColorToken.Text.primary + case .secondary: + ColorToken.Text.secondary + case .tertiary: + ColorToken.Text.tertiary + } + } + + var lineLimit: Int { + switch self { + case .primary, .secondary: + 1 + case .tertiary: + 2 + } + } +} + +private enum EntryDetailContextCategory { + case neutral + case pain(PainLevel) + case note + + var iconColor: Color { + switch self { + case .neutral: + ColorToken.Neutral.icon + case let .pain(level): + ColorToken.Pain.token(for: level).icon + case .note: + ColorToken.Text.secondary + } + } +} + +private extension EpisodeRecord { + var hasVisibleTriggers: Bool { + triggers.contains { !$0.trimmed.isEmpty } + } } private extension String { diff --git a/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift b/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift index cd79fb4..4ef42fe 100644 --- a/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift +++ b/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift @@ -21,6 +21,16 @@ struct SymiColorValue: Equatable, Sendable { Color(red: red, green: green, blue: blue) } + func mixed(with other: SymiColorValue, amount: Double) -> SymiColorValue { + let clampedAmount = min(max(amount, 0), 1) + let inverseAmount = 1 - clampedAmount + return SymiColorValue( + red: red * inverseAmount + other.red * clampedAmount, + green: green * inverseAmount + other.green * clampedAmount, + blue: blue * inverseAmount + other.blue * clampedAmount + ) + } + var hexString: String { "#\(Self.hexByte(red))\(Self.hexByte(green))\(Self.hexByte(blue))" } @@ -32,6 +42,26 @@ struct SymiColorValue: Equatable, Sendable { } } +nonisolated enum PainLevel: Sendable { + case none + case low + case medium + case high + + init(intensity: Int) { + switch intensity { + case 1 ... 3: + self = .low + case 4 ... 6: + self = .medium + case 7 ... 10: + self = .high + default: + self = .none + } + } +} + enum SymiColors { static let primaryPetrol = SymiColorValue(hex: 0x0F3D3E) static let sage = SymiColorValue(hex: 0x8ECDB8) @@ -53,6 +83,11 @@ enum SymiColors { static let triggerBlue = SymiColorValue(hex: 0x4A78D9) static let noteAmber = SymiColorValue(hex: 0xD18A2B) static let reviewPurple = SymiColorValue(hex: 0x8A65D6) + static let entryDetailCard = SymiColorValue(hex: 0xFFFFFB) + static let entryDetailIconFill = SymiColorValue(hex: 0xECF3E4) + static let entryDetailFaceFill = SymiColorValue(hex: 0xF6EAD5) + static let entryDetailProgressWarmMid = SymiColorValue(hex: 0xE6BA75) + static let entryDetailProgressSageMid = SymiColorValue(hex: 0xC2D19E) static let petrolDark = SymiColorValue(hex: 0x8ECDB8) static let coralDark = SymiColorValue(hex: 0xFFA196) @@ -88,3 +123,124 @@ enum SymiColors { Color.primary.opacity(colorScheme == .dark ? SymiOpacity.softFill : SymiOpacity.hairline) } } + +enum ColorToken { + enum Text { + static let primary = SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailPrimaryText) + static let secondary = SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailSecondaryText) + static let tertiary = SymiColors.textSecondary.color.opacity(SymiOpacity.entryDetailTertiaryText) + static let label = SymiColors.textSecondary.color + static let onSurface = SymiColors.textPrimary.color + static let destructive = SymiColors.intensityStrong.color.opacity(SymiOpacity.entryDetailDeleteText) + } + + enum Surface { + static let appBackground = SymiColors.warmBackground.color + static let primary = SymiColors.entryDetailCard.color + static let headerControlBackground = SymiColors.onAccent.color + static let cardHighlight = SymiColors.onAccent.color.opacity(SymiOpacity.entryDetailHighlight) + static let iconBackground = SymiColors.entryDetailIconFill.color + static let progressTrack = SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailProgressTrack) + static let progressHighlight = SymiColors.onAccent.color.opacity(SymiOpacity.entryDetailProgressHighlight) + static let topFade = LinearGradient( + colors: [ + SymiColors.warmBackground.color, + SymiColors.warmBackground.color.opacity(SymiOpacity.entryDetailTopFadeEnd) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + enum Shadow { + static let card = SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailShadow) + } + + enum Brand { + static let primary = SymiColors.primaryPetrol.color + } + + enum Neutral { + static let icon = SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailIcon) + } + + enum Medication { + static let foreground = SymiColors.sage.color + } + + enum Trigger { + static let foreground = SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailTriggerChipText) + static let background = SymiColors.sage.color.opacity(SymiOpacity.entryDetailTriggerChipFill) + } + + enum Pain { + static func token(for level: PainLevel) -> PainToken { + PainToken(level: level) + } + } +} + +struct PainToken { + let level: PainLevel + + var foreground: Color { + baseValue.color + } + + var icon: Color { + baseValue.color.opacity(SymiOpacity.entryDetailIcon) + } + + var emphasizedText: Color { + level == .high ? foreground : ColorToken.Text.primary + } + + var descriptionText: Color { + level == .high ? foreground : ColorToken.Text.secondary + } + + var faceBackground: Color { + switch level { + case .none: + ColorToken.Surface.iconBackground + case .low: + SymiColors.entryDetailIconFill.color + case .medium: + SymiColors.entryDetailFaceFill.color + case .high: + SymiColors.coral.color.opacity(SymiOpacity.clearAccent) + } + } + + var progressGradient: LinearGradient { + LinearGradient( + colors: [ + darkerValue.color, + lighterValue.color + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + private var baseValue: SymiColorValue { + switch level { + case .none: + SymiColors.textSecondary + case .low: + SymiColors.intensityLight + case .medium: + SymiColors.intensityMedium + case .high: + SymiColors.intensityStrong + } + } + + private var darkerValue: SymiColorValue { + baseValue.mixed(with: SymiColors.primaryPetrol, amount: SymiOpacity.clearAccent) + } + + private var lighterValue: SymiColorValue { + baseValue.mixed(with: SymiColors.onAccent, amount: SymiOpacity.backgroundAccent) + } +} diff --git a/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift b/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift index 78752e5..a78cbbc 100644 --- a/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift +++ b/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift @@ -45,6 +45,21 @@ nonisolated enum SymiSpacing { static let homeLiquidBackgroundPrimaryOffsetY: CGFloat = -260 static let homeLiquidBackgroundSecondaryOffsetX: CGFloat = 170 static let homeLiquidBackgroundSecondaryOffsetY: CGFloat = 260 + static let entryDetailBottomPadding: CGFloat = 76 + static let entryDetailHeroSpacing: CGFloat = 25 + static let entryDetailHeroPadding: CGFloat = 26 + static let entryDetailContextRowSpacing: CGFloat = 11 + static let entryDetailContextHorizontalPadding: CGFloat = 22 + static let entryDetailContextVerticalPadding: CGFloat = 10 + static let entryDetailContextRowVerticalPadding: CGFloat = 18 + static let entryDetailTriggerSectionSpacing: CGFloat = 14 + static let entryDetailTriggerCardPadding: CGFloat = 18 + static let entryDetailTriggerGridSpacing: CGFloat = 10 + static let entryDetailTriggerGridColumnSpacing: CGFloat = 9 + static let entryDetailTriggerChipHorizontalPadding: CGFloat = 15 + static let entryDetailTriggerChipVerticalPadding: CGFloat = 9 + static let entryDetailMedicationCardPadding: CGFloat = 22 + static let entryDetailDeleteBottomPadding: CGFloat = 38 } nonisolated enum SymiRadius { @@ -165,6 +180,14 @@ nonisolated enum SymiSize { static let painGaugeHeight: CGFloat = 148 static let homeLiquidBackgroundPrimaryBlur: CGFloat = 70 static let homeLiquidBackgroundSecondaryBlur: CGFloat = 86 + static let entryDetailTopFadeHeight: CGFloat = 16 + static let entryDetailFaceBadge: CGFloat = 58 + static let entryDetailFaceIcon: CGFloat = 32 + static let entryDetailProgressBarHeight: CGFloat = 11 + static let entryDetailProgressHighlightHeight: CGFloat = 2 + static let entryDetailContextIcon: CGFloat = 32 + static let entryDetailTriggerGridMinWidth: CGFloat = 118 + static let entryDetailDeleteHeight: CGFloat = 54 static let emptyStateMinHeight: CGFloat = 360 static let defaultWindowWidth: CGFloat = 1280 static let defaultWindowHeight: CGFloat = 800 @@ -188,6 +211,7 @@ nonisolated enum SymiStroke { static let heroWaveSecondary: CGFloat = 5 static let heroWavePrimary: CGFloat = 8 static let painGaugeArc: CGFloat = 18 + static let entryDetailFaceIcon: CGFloat = 2 } nonisolated enum SymiAnimation { @@ -253,4 +277,22 @@ nonisolated enum SymiOpacity { static let homeBackgroundPrimaryDark: Double = 0.08 static let homeBackgroundSecondaryLight: Double = 0.10 static let homeBackgroundSecondaryDark: Double = 0.05 + static let entryDetailTopFadeEnd: Double = 0 + static let entryDetailShadow: Double = 0.022 + static let entryDetailHighlight: Double = 0.74 + static let entryDetailPrimaryText: Double = 0.98 + static let entryDetailBodyText: Double = 0.82 + static let entryDetailFaceStroke: Double = 0.60 + static let entryDetailProgressTrack: Double = 0.065 + static let entryDetailProgressStart: Double = 0.98 + static let entryDetailProgressEnd: Double = 0.98 + static let entryDetailProgressHighlight: Double = 0.14 + static let entryDetailIcon: Double = 0.62 + static let entryDetailTriggerChipText: Double = 0.84 + static let entryDetailTriggerChipFill: Double = 0.22 + static let entryDetailDeleteText: Double = 0.78 + static let entryDetailDeletePressed: Double = 0.68 + static let entryDetailDeleteScale: Double = 0.98 + static let entryDetailSecondaryText: Double = 0.84 + static let entryDetailTertiaryText: Double = 0.82 } diff --git a/Symi/Sources/Features/InputFlow/Styling/SymiTypography.swift b/Symi/Sources/Features/InputFlow/Styling/SymiTypography.swift index 07568f9..d04dd87 100644 --- a/Symi/Sources/Features/InputFlow/Styling/SymiTypography.swift +++ b/Symi/Sources/Features/InputFlow/Styling/SymiTypography.swift @@ -19,6 +19,7 @@ enum SymiTypography { static let painGaugeUnit = Font.footnote.weight(.medium) static let painGaugeLabel = Font.title3.weight(.semibold) static let homeMetric = Font.system(size: 44, weight: .bold, design: .rounded) + static let entryDetailIntensityTitle = Font.system(size: 44, weight: .bold, design: .rounded) static let flowTitle = Font.system(size: 24, weight: .bold, design: .rounded) static let flowSubtitle = Font.callout static let flowSectionTitle = Font.subheadline.weight(.regular)