From b312855b83227641c95f34d2d80348004543845c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:22:58 +0200 Subject: [PATCH 1/6] V2 --- Symi/Resources/Localizable.xcstrings | 9 + .../Features/History/EpisodeDetailView.swift | 358 +++++++++++++----- 2 files changed, 263 insertions(+), 104 deletions(-) diff --git a/Symi/Resources/Localizable.xcstrings b/Symi/Resources/Localizable.xcstrings index 0b6de11..07ba45a 100644 --- a/Symi/Resources/Localizable.xcstrings +++ b/Symi/Resources/Localizable.xcstrings @@ -1199,6 +1199,9 @@ } } } + }, + "Eingenommen" : { + }, "Einschränkung: %@" : { "localizations" : { @@ -1904,6 +1907,9 @@ }, "Keine aktive Dauermedikation." : { + }, + "Keine Auslöser dokumentiert." : { + }, "Keine beendete Dauermedikation." : { @@ -2283,6 +2289,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..02818ea 100644 --- a/Symi/Sources/Features/History/EpisodeDetailView.swift +++ b/Symi/Sources/Features/History/EpisodeDetailView.swift @@ -30,7 +30,7 @@ struct EpisodeDetailView: View { var body: some View { ScrollView { - VStack(alignment: .leading, spacing: SymiSpacing.xl) { + VStack(alignment: .leading, spacing: 22) { EntryDetailHeader( title: headerTitle, onBack: { dismiss() }, @@ -40,21 +40,20 @@ struct EpisodeDetailView: View { if let episode { EntryDetailHeroCard(episode: episode) - if !detailRows.isEmpty { - EntryDetailSectionCard(rows: detailRows) - } + EntryDetailContextCard(episode: episode) - EntryDetailActions( - onEdit: { isEditing = true }, - onDelete: { isShowingDeleteConfirmation = true } - ) + EntryDetailTriggerSection(triggers: episode.triggers) + + EntryDetailMedicationCard(episode: episode) + + EntryDetailDeleteAction(onDelete: { isShowingDeleteConfirmation = true }) } else { EntryDetailLoadingState(isLoading: isLoading) } } .padding(.horizontal, SymiSpacing.xxl) - .padding(.top, SymiSpacing.xl) - .padding(.bottom, SymiSpacing.xxxl) + .padding(.top, 18) + .padding(.bottom, 34) } .background(SymiColors.warmBackground.color.ignoresSafeArea()) .navigationTitle("") @@ -94,44 +93,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 { @@ -181,15 +142,12 @@ private struct EntryDetailHeader: View { .foregroundStyle(SymiColors.textPrimary.color) .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) .frame(minHeight: SymiSize.minInteractiveHeight) - .background(SymiColors.onAccent.color, in: Capsule()) .buttonStyle(.plain) } } @@ -203,96 +161,288 @@ private struct EntryDetailHeroCard: View { } 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) + VStack(alignment: .leading, spacing: 26) { + HStack(alignment: .top, spacing: SymiSpacing.lg) { + VStack(alignment: .leading, spacing: SymiSpacing.xs) { + Text("Intensität") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(SymiColors.textSecondary.color) + + Text(JournalEntryContext.intensityLabel(for: episode.intensity)) + .font(.system(size: 44, weight: .bold, design: .rounded)) + .foregroundStyle(SymiColors.textPrimary.color) + .minimumScaleFactor(SymiTypography.compactScaleFactor) + + Text("\(episode.intensity)/10") + .font(.system(.title2, design: .rounded).weight(.semibold)) + .foregroundStyle(SymiColors.textSecondary.color) + .monospacedDigit() + } - Text("\(episode.intensity)/10") - .font(.system(.title2, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textSecondary.color) - .monospacedDigit() + Spacer(minLength: SymiSpacing.md) + + EntryDetailFaceBadge() } - ProgressView(value: Double(episode.intensity), total: 10) - .tint(intensityColor) + EntryDetailProgressBar(value: Double(episode.intensity) / 10) .accessibilityLabel("Intensität") .accessibilityValue("\(episode.intensity) von 10") + + Text(intensityDescription) + .font(.system(.body, design: .rounded).weight(.medium)) + .foregroundStyle(SymiColors.textPrimary.color.opacity(0.82)) + .fixedSize(horizontal: false, vertical: true) } - .padding(SymiSpacing.xxl) + .padding(26) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous)) + .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous) - .stroke(Color.primary.opacity(SymiOpacity.journalBorder), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.white.opacity(0.82), lineWidth: SymiStroke.hairline) ) .shadow( - color: Color.primary.opacity(SymiOpacity.journalShadow), - radius: SymiShadow.journalCardRadius, + color: SymiColors.primaryPetrol.color.opacity(0.06), + radius: 24, x: SymiShadow.journalCardXOffset, - y: SymiShadow.journalCardYOffset + y: 12 ) } + + 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 { + var body: some View { + ZStack { + Circle() + .fill(Color(red: 0.95, green: 0.91, blue: 0.84)) + + CalmFaceIcon() + .stroke(SymiColors.textPrimary.color.opacity(0.70), style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round)) + .frame(width: 30, height: 30) + } + .frame(width: 58, height: 58) + .accessibilityHidden(true) + } +} + +private struct CalmFaceIcon: Shape { + 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: height * 0.40)) + path.addLine(to: CGPoint(x: width * 0.34, y: height * 0.42)) + + path.move(to: CGPoint(x: width * 0.66, y: height * 0.40)) + path.addLine(to: CGPoint(x: width * 0.66, y: height * 0.42)) + + path.move(to: CGPoint(x: width * 0.36, y: height * 0.63)) + path.addQuadCurve( + to: CGPoint(x: width * 0.64, y: height * 0.63), + control: CGPoint(x: width * 0.50, y: height * 0.70) + ) + + return path + } +} + +private struct EntryDetailProgressBar: View { + let value: Double + + var body: some View { + GeometryReader { proxy in + let clampedValue = min(max(value, 0), 1) + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + + Capsule() + .fill( + LinearGradient( + colors: [ + Color(red: 0.78, green: 0.54, blue: 0.27), + SymiColors.sage.color.opacity(0.88) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: proxy.size.width * clampedValue) + } + } + .frame(height: 8) + } } -private struct EntryDetailSectionCard: View { - let rows: [EntryDetailRowModel] +private struct EntryDetailContextCard: View { + let episode: EpisodeRecord + + private var rows: [EntryDetailContextRowModel] { + [ + EntryDetailContextRowModel(systemImage: "clock", title: JournalEntryContext.timeOfDay(for: episode.startedAt)), + EntryDetailContextRowModel(systemImage: "head.profile", title: painLocationText), + EntryDetailContextRowModel(systemImage: "note.text", title: noteText, isMultiline: true) + ] + } var body: some View { - VStack(alignment: .leading, spacing: SymiSpacing.lg) { - ForEach(rows) { row in - EntryDetailRow(row: row) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + EntryDetailContextRow(row: row) + + if index < rows.count - 1 { + Divider() + .overlay(Color.primary.opacity(0.06)) + .padding(.leading, 48) + } } } - .padding(SymiSpacing.xxl) + .padding(.horizontal, 22) + .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous)) + .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: SymiRadius.journalCard, style: .continuous) - .stroke(Color.primary.opacity(SymiOpacity.journalBorder), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.white.opacity(0.78), lineWidth: SymiStroke.hairline) ) + .shadow(color: SymiColors.primaryPetrol.color.opacity(0.035), radius: 16, x: 0, y: 8) + } + + 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: 16) { + Image(systemName: row.systemImage) + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .foregroundStyle(SymiColors.primaryPetrol.color.opacity(0.72)) + .frame(width: 32, height: 32) + .background(SymiColors.sage.color.opacity(0.16), in: Circle()) - Text(row.value) - .font(.system(.body, design: .rounded)) + Text(row.title) + .font(.system(.body, design: .rounded).weight(.medium)) .foregroundStyle(SymiColors.textPrimary.color) .lineLimit(row.isMultiline ? nil : 2) .fixedSize(horizontal: false, vertical: true) } + .padding(.vertical, 18) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } } -private struct EntryDetailActions: View { - let onEdit: () -> Void - let onDelete: () -> Void +private struct EntryDetailTriggerSection: View { + let triggers: [String] 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) + VStack(alignment: .leading, spacing: 14) { + Text("Mögliche Auslöser") + .font(.system(.headline, design: .rounded).weight(.semibold)) + .foregroundStyle(SymiColors.textPrimary.color) + + if visibleTriggers.isEmpty { + Text("Keine Auslöser dokumentiert.") + .font(.system(.subheadline, design: .rounded).weight(.medium)) + .foregroundStyle(SymiColors.textSecondary.color) + } else { + LazyVGrid(columns: columns, alignment: .leading, spacing: 9) { + ForEach(visibleTriggers, id: \.self) { trigger in + EntryDetailTriggerChip(title: trigger) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var visibleTriggers: [String] { + triggers.map(\.trimmed).filter { !$0.isEmpty } + } + + private var columns: [GridItem] { + [GridItem(.adaptive(minimum: 118), spacing: 9, alignment: .leading)] + } +} + +private struct EntryDetailTriggerChip: View { + let title: String + + var body: some View { + Text(title) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(SymiColors.textPrimary.color.opacity(0.84)) + .lineLimit(1) + .minimumScaleFactor(SymiTypography.tightChipScaleFactor) + .padding(.horizontal, 15) + .padding(.vertical, 9) + .background(SymiColors.sage.color.opacity(0.26), in: Capsule()) + } +} + +private struct EntryDetailMedicationCard: View { + let episode: EpisodeRecord + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Eingenommen") + .font(.system(.headline, design: .rounded).weight(.semibold)) + .foregroundStyle(SymiColors.textPrimary.color) + + Text(medicationText) + .font(.system(.body, design: .rounded).weight(.medium)) + .foregroundStyle(SymiColors.textSecondary.color) + .fixedSize(horizontal: false, vertical: true) } - .buttonStyle(.plain) + .padding(22) + .frame(maxWidth: .infinity, alignment: .leading) + .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.white.opacity(0.78), lineWidth: SymiStroke.hairline) + ) + .shadow(color: SymiColors.primaryPetrol.color.opacity(0.03), radius: 14, x: 0, y: 6) + } + + private var medicationText: String { + JournalEntryContext.medicationDetail(for: episode) ?? "Keine Medikation dokumentiert." + } +} + +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(SymiColors.intensityStrong.color) + .frame(maxWidth: .infinity, minHeight: 54) + .buttonStyle(.plain) + .padding(.top, 2) } } @@ -311,10 +461,10 @@ 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 } From e860dcaae9c8c667e3febfb36327370a9c7d9675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:34:38 +0200 Subject: [PATCH 2/6] V5 --- Symi/Resources/Localizable.xcstrings | 3 - .../Features/History/EpisodeDetailView.swift | 225 ++++++++++++------ 2 files changed, 151 insertions(+), 77 deletions(-) diff --git a/Symi/Resources/Localizable.xcstrings b/Symi/Resources/Localizable.xcstrings index 07ba45a..446f8b7 100644 --- a/Symi/Resources/Localizable.xcstrings +++ b/Symi/Resources/Localizable.xcstrings @@ -1907,9 +1907,6 @@ }, "Keine aktive Dauermedikation." : { - }, - "Keine Auslöser dokumentiert." : { - }, "Keine beendete Dauermedikation." : { diff --git a/Symi/Sources/Features/History/EpisodeDetailView.swift b/Symi/Sources/Features/History/EpisodeDetailView.swift index 02818ea..c755719 100644 --- a/Symi/Sources/Features/History/EpisodeDetailView.swift +++ b/Symi/Sources/Features/History/EpisodeDetailView.swift @@ -30,7 +30,7 @@ struct EpisodeDetailView: View { var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 24) { EntryDetailHeader( title: headerTitle, onBack: { dismiss() }, @@ -42,7 +42,9 @@ struct EpisodeDetailView: View { EntryDetailContextCard(episode: episode) - EntryDetailTriggerSection(triggers: episode.triggers) + if episode.hasVisibleTriggers { + EntryDetailTriggerSection(triggers: episode.triggers) + } EntryDetailMedicationCard(episode: episode) @@ -53,9 +55,21 @@ struct EpisodeDetailView: View { } .padding(.horizontal, SymiSpacing.xxl) .padding(.top, 18) - .padding(.bottom, 34) + .padding(.bottom, 76) } .background(SymiColors.warmBackground.color.ignoresSafeArea()) + .overlay(alignment: .top) { + LinearGradient( + colors: [ + SymiColors.warmBackground.color, + SymiColors.warmBackground.color.opacity(0) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 16) + .allowsHitTesting(false) + } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) @@ -156,27 +170,25 @@ private struct EntryDetailHeader: View { private struct EntryDetailHeroCard: View { let episode: EpisodeRecord - private var intensityColor: Color { - JournalEntryContext.intensityColor(for: episode.intensity) - } - var body: some View { VStack(alignment: .leading, spacing: 26) { HStack(alignment: .top, spacing: SymiSpacing.lg) { - VStack(alignment: .leading, spacing: SymiSpacing.xs) { + VStack(alignment: .leading, spacing: 7) { Text("Intensität") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(SymiColors.textSecondary.color) - Text(JournalEntryContext.intensityLabel(for: episode.intensity)) - .font(.system(size: 44, weight: .bold, design: .rounded)) - .foregroundStyle(SymiColors.textPrimary.color) - .minimumScaleFactor(SymiTypography.compactScaleFactor) + VStack(alignment: .leading, spacing: 4) { + Text(JournalEntryContext.intensityLabel(for: episode.intensity)) + .font(.system(size: 44, weight: .bold, design: .rounded)) + .foregroundStyle(SymiColors.textPrimary.color) + .minimumScaleFactor(SymiTypography.compactScaleFactor) - Text("\(episode.intensity)/10") - .font(.system(.title2, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textSecondary.color) - .monospacedDigit() + Text("\(episode.intensity)/10") + .font(.system(.title2, design: .rounded).weight(.semibold)) + .foregroundStyle(SymiColors.textSecondary.color) + .monospacedDigit() + } } Spacer(minLength: SymiSpacing.md) @@ -195,16 +207,16 @@ private struct EntryDetailHeroCard: View { } .padding(26) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 30, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .stroke(Color.white.opacity(0.82), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke(Color.white.opacity(0.76), lineWidth: SymiStroke.hairline) ) .shadow( - color: SymiColors.primaryPetrol.color.opacity(0.06), - radius: 24, + color: SymiColors.primaryPetrol.color.opacity(0.045), + radius: 28, x: SymiShadow.journalCardXOffset, - y: 12 + y: 14 ) } @@ -226,11 +238,11 @@ private struct EntryDetailFaceBadge: View { var body: some View { ZStack { Circle() - .fill(Color(red: 0.95, green: 0.91, blue: 0.84)) + .fill(Color(red: 0.96, green: 0.91, blue: 0.82)) CalmFaceIcon() - .stroke(SymiColors.textPrimary.color.opacity(0.70), style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round)) - .frame(width: 30, height: 30) + .stroke(SymiColors.primaryPetrol.color.opacity(0.62), style: StrokeStyle(lineWidth: 2.05, lineCap: .round, lineJoin: .round)) + .frame(width: 31, height: 31) } .frame(width: 58, height: 58) .accessibilityHidden(true) @@ -254,7 +266,7 @@ private struct CalmFaceIcon: Shape { path.move(to: CGPoint(x: width * 0.36, y: height * 0.63)) path.addQuadCurve( to: CGPoint(x: width * 0.64, y: height * 0.63), - control: CGPoint(x: width * 0.50, y: height * 0.70) + control: CGPoint(x: width * 0.50, y: height * 0.67) ) return path @@ -270,23 +282,30 @@ private struct EntryDetailProgressBar: View { ZStack(alignment: .leading) { Capsule() - .fill(Color.primary.opacity(0.08)) + .fill(Color.primary.opacity(0.095)) Capsule() .fill( LinearGradient( - colors: [ - Color(red: 0.78, green: 0.54, blue: 0.27), - SymiColors.sage.color.opacity(0.88) - ], + gradient: Gradient(stops: [ + .init(color: SymiColors.noteAmber.color.opacity(0.98), location: 0), + .init(color: Color(red: 0.78, green: 0.64, blue: 0.43), location: 0.46), + .init(color: SymiColors.sage.color.opacity(0.98), location: 1) + ]), startPoint: .leading, endPoint: .trailing ) ) + .overlay(alignment: .top) { + Capsule() + .fill(Color.white.opacity(0.18)) + .frame(height: 2) + .padding(.horizontal, 1) + } .frame(width: proxy.size.width * clampedValue) } } - .frame(height: 8) + .frame(height: 11) } } @@ -296,32 +315,26 @@ private struct EntryDetailContextCard: View { private var rows: [EntryDetailContextRowModel] { [ EntryDetailContextRowModel(systemImage: "clock", title: JournalEntryContext.timeOfDay(for: episode.startedAt)), - EntryDetailContextRowModel(systemImage: "head.profile", title: painLocationText), - EntryDetailContextRowModel(systemImage: "note.text", title: noteText, isMultiline: true) + EntryDetailContextRowModel(systemImage: "brain.head.profile", title: painLocationText, hierarchy: .secondary), + EntryDetailContextRowModel(systemImage: "note.text", title: noteText, hierarchy: .tertiary) ] } var body: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + VStack(alignment: .leading, spacing: 6) { + ForEach(rows) { row in EntryDetailContextRow(row: row) - - if index < rows.count - 1 { - Divider() - .overlay(Color.primary.opacity(0.06)) - .padding(.leading, 48) - } } } .padding(.horizontal, 22) - .padding(.vertical, 8) + .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(Color.white.opacity(0.78), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke(Color.white.opacity(0.72), lineWidth: SymiStroke.hairline) ) - .shadow(color: SymiColors.primaryPetrol.color.opacity(0.035), radius: 16, x: 0, y: 8) + .shadow(color: SymiColors.primaryPetrol.color.opacity(0.026), radius: 20, x: 0, y: 10) } private var painLocationText: String { @@ -338,24 +351,33 @@ private struct EntryDetailContextRow: View { var body: some View { HStack(alignment: .center, spacing: 16) { - Image(systemName: row.systemImage) - .font(.system(size: 17, weight: .semibold, design: .rounded)) - .foregroundStyle(SymiColors.primaryPetrol.color.opacity(0.72)) - .frame(width: 32, height: 32) - .background(SymiColors.sage.color.opacity(0.16), in: Circle()) + EntryDetailContextIcon(systemImage: row.systemImage) Text(row.title) - .font(.system(.body, design: .rounded).weight(.medium)) - .foregroundStyle(SymiColors.textPrimary.color) - .lineLimit(row.isMultiline ? nil : 2) + .font(row.hierarchy.font) + .foregroundStyle(row.hierarchy.color) + .lineLimit(row.hierarchy.lineLimit) .fixedSize(horizontal: false, vertical: true) } - .padding(.vertical, 18) + .padding(.vertical, 20) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } } +private struct EntryDetailContextIcon: View { + let systemImage: String + + var body: some View { + Image(systemName: systemImage) + .symbolRenderingMode(.monochrome) + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(SymiColors.primaryPetrol.color.opacity(0.64)) + .frame(width: 32, height: 32) + .background(SymiColors.sage.color.opacity(0.16), in: Circle()) + } +} + private struct EntryDetailTriggerSection: View { let triggers: [String] @@ -365,17 +387,19 @@ private struct EntryDetailTriggerSection: View { .font(.system(.headline, design: .rounded).weight(.semibold)) .foregroundStyle(SymiColors.textPrimary.color) - if visibleTriggers.isEmpty { - Text("Keine Auslöser dokumentiert.") - .font(.system(.subheadline, design: .rounded).weight(.medium)) - .foregroundStyle(SymiColors.textSecondary.color) - } else { - LazyVGrid(columns: columns, alignment: .leading, spacing: 9) { - ForEach(visibleTriggers, id: \.self) { trigger in - EntryDetailTriggerChip(title: trigger) - } + LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { + ForEach(visibleTriggers, id: \.self) { trigger in + EntryDetailTriggerChip(title: trigger) } } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.white.opacity(0.72), lineWidth: SymiStroke.hairline) + ) + .shadow(color: SymiColors.primaryPetrol.color.opacity(0.024), radius: 18, x: 0, y: 8) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -400,7 +424,7 @@ private struct EntryDetailTriggerChip: View { .minimumScaleFactor(SymiTypography.tightChipScaleFactor) .padding(.horizontal, 15) .padding(.vertical, 9) - .background(SymiColors.sage.color.opacity(0.26), in: Capsule()) + .background(SymiColors.sage.color.opacity(0.22), in: Capsule()) } } @@ -420,16 +444,16 @@ private struct EntryDetailMedicationCard: View { } .padding(22) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Color.white.opacity(0.78), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.white.opacity(0.72), lineWidth: SymiStroke.hairline) ) - .shadow(color: SymiColors.primaryPetrol.color.opacity(0.03), radius: 14, x: 0, y: 6) + .shadow(color: SymiColors.primaryPetrol.color.opacity(0.024), radius: 18, x: 0, y: 8) } private var medicationText: String { - JournalEntryContext.medicationDetail(for: episode) ?? "Keine Medikation dokumentiert." + JournalEntryContext.medicationDetail(for: episode) ?? "Keine Medikation" } } @@ -439,10 +463,20 @@ private struct EntryDetailDeleteAction: View { var body: some View { Button("Löschen", role: .destructive, action: onDelete) .font(.system(.body, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.intensityStrong.color) + .foregroundStyle(SymiColors.intensityStrong.color.opacity(0.84)) .frame(maxWidth: .infinity, minHeight: 54) - .buttonStyle(.plain) + .buttonStyle(EntryDetailDestructiveTextButtonStyle()) .padding(.top, 2) + .padding(.bottom, 38) + } +} + +private struct EntryDetailDestructiveTextButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.62 : 1) + .scaleEffect(configuration.isPressed ? 0.992 : 1) + .animation(.easeOut(duration: SymiAnimation.quickDuration), value: configuration.isPressed) } } @@ -465,7 +499,50 @@ private struct EntryDetailContextRowModel: Identifiable { let id = UUID() let systemImage: String let title: String - var isMultiline = false + var hierarchy: EntryDetailContextHierarchy = .primary +} + +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: + SymiColors.textPrimary.color + case .secondary: + SymiColors.textPrimary.color.opacity(0.84) + case .tertiary: + SymiColors.textSecondary.color + } + } + + var lineLimit: Int { + switch self { + case .primary, .secondary: + 1 + case .tertiary: + 2 + } + } +} + +private extension EpisodeRecord { + var hasVisibleTriggers: Bool { + triggers.contains { !$0.trimmed.isEmpty } + } } private extension String { From 0b8e63fa0b5459ca874ca8ecbfcf4b5d946aae3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:39:46 +0200 Subject: [PATCH 3/6] V6 --- .../Features/History/EpisodeDetailView.swift | 110 ++++++++++++------ 1 file changed, 72 insertions(+), 38 deletions(-) diff --git a/Symi/Sources/Features/History/EpisodeDetailView.swift b/Symi/Sources/Features/History/EpisodeDetailView.swift index c755719..ecc9796 100644 --- a/Symi/Sources/Features/History/EpisodeDetailView.swift +++ b/Symi/Sources/Features/History/EpisodeDetailView.swift @@ -167,21 +167,31 @@ private struct EntryDetailHeader: View { } } +private enum EntryDetailSurface { + static let cornerRadius: CGFloat = 28 + static let shadowColor = SymiColors.primaryPetrol.color.opacity(0.026) + static let shadowRadius: CGFloat = 22 + static let shadowYOffset: CGFloat = 10 + static let cardFill = Color(red: 1.0, green: 0.996, blue: 0.982) + static let highlight = Color.white.opacity(0.74) + static let iconFill = Color(red: 0.91, green: 0.95, blue: 0.90) +} + private struct EntryDetailHeroCard: View { let episode: EpisodeRecord var body: some View { - VStack(alignment: .leading, spacing: 26) { + VStack(alignment: .leading, spacing: 25) { HStack(alignment: .top, spacing: SymiSpacing.lg) { - VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 6) { Text("Intensität") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(SymiColors.textSecondary.color) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 1) { Text(JournalEntryContext.intensityLabel(for: episode.intensity)) .font(.system(size: 44, weight: .bold, design: .rounded)) - .foregroundStyle(SymiColors.textPrimary.color) + .foregroundStyle(SymiColors.textPrimary.color.opacity(0.98)) .minimumScaleFactor(SymiTypography.compactScaleFactor) Text("\(episode.intensity)/10") @@ -207,16 +217,16 @@ private struct EntryDetailHeroCard: View { } .padding(26) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 30, style: .continuous)) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 30, style: .continuous) - .stroke(Color.white.opacity(0.76), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) ) .shadow( - color: SymiColors.primaryPetrol.color.opacity(0.045), - radius: 28, + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, x: SymiShadow.journalCardXOffset, - y: 14 + y: EntryDetailSurface.shadowYOffset ) } @@ -238,11 +248,11 @@ private struct EntryDetailFaceBadge: View { var body: some View { ZStack { Circle() - .fill(Color(red: 0.96, green: 0.91, blue: 0.82)) + .fill(Color(red: 0.965, green: 0.918, blue: 0.835)) CalmFaceIcon() - .stroke(SymiColors.primaryPetrol.color.opacity(0.62), style: StrokeStyle(lineWidth: 2.05, lineCap: .round, lineJoin: .round)) - .frame(width: 31, height: 31) + .stroke(SymiColors.primaryPetrol.color.opacity(0.60), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .frame(width: 32, height: 32) } .frame(width: 58, height: 58) .accessibilityHidden(true) @@ -282,14 +292,15 @@ private struct EntryDetailProgressBar: View { ZStack(alignment: .leading) { Capsule() - .fill(Color.primary.opacity(0.095)) + .fill(Color.primary.opacity(0.075)) Capsule() .fill( LinearGradient( gradient: Gradient(stops: [ .init(color: SymiColors.noteAmber.color.opacity(0.98), location: 0), - .init(color: Color(red: 0.78, green: 0.64, blue: 0.43), location: 0.46), + .init(color: Color(red: 0.86, green: 0.69, blue: 0.43), location: 0.42), + .init(color: Color(red: 0.70, green: 0.78, blue: 0.58), location: 0.68), .init(color: SymiColors.sage.color.opacity(0.98), location: 1) ]), startPoint: .leading, @@ -298,7 +309,7 @@ private struct EntryDetailProgressBar: View { ) .overlay(alignment: .top) { Capsule() - .fill(Color.white.opacity(0.18)) + .fill(Color.white.opacity(0.14)) .frame(height: 2) .padding(.horizontal, 1) } @@ -321,7 +332,7 @@ private struct EntryDetailContextCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 11) { ForEach(rows) { row in EntryDetailContextRow(row: row) } @@ -329,12 +340,12 @@ private struct EntryDetailContextCard: View { .padding(.horizontal, 22) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 26, style: .continuous) - .stroke(Color.white.opacity(0.72), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) ) - .shadow(color: SymiColors.primaryPetrol.color.opacity(0.026), radius: 20, x: 0, y: 10) + .shadow(color: EntryDetailSurface.shadowColor, radius: EntryDetailSurface.shadowRadius, x: 0, y: EntryDetailSurface.shadowYOffset) } private var painLocationText: String { @@ -359,7 +370,7 @@ private struct EntryDetailContextRow: View { .lineLimit(row.hierarchy.lineLimit) .fixedSize(horizontal: false, vertical: true) } - .padding(.vertical, 20) + .padding(.vertical, 18) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } @@ -371,10 +382,33 @@ private struct EntryDetailContextIcon: View { var body: some View { Image(systemName: systemImage) .symbolRenderingMode(.monochrome) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .foregroundStyle(SymiColors.primaryPetrol.color.opacity(0.64)) + .font(.system(size: iconSize, weight: .medium, design: .rounded)) + .foregroundStyle(SymiColors.primaryPetrol.color.opacity(0.62)) .frame(width: 32, height: 32) - .background(SymiColors.sage.color.opacity(0.16), in: Circle()) + .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 + } } } @@ -394,12 +428,12 @@ private struct EntryDetailTriggerSection: View { } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(Color.white.opacity(0.72), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) ) - .shadow(color: SymiColors.primaryPetrol.color.opacity(0.024), radius: 18, x: 0, y: 8) + .shadow(color: EntryDetailSurface.shadowColor, radius: EntryDetailSurface.shadowRadius, x: 0, y: EntryDetailSurface.shadowYOffset) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -444,12 +478,12 @@ private struct EntryDetailMedicationCard: View { } .padding(22) .frame(maxWidth: .infinity, alignment: .leading) - .background(SymiColors.onAccent.color, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(Color.white.opacity(0.72), lineWidth: SymiStroke.hairline) + RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous) + .stroke(EntryDetailSurface.highlight, lineWidth: SymiStroke.hairline) ) - .shadow(color: SymiColors.primaryPetrol.color.opacity(0.024), radius: 18, x: 0, y: 8) + .shadow(color: EntryDetailSurface.shadowColor, radius: EntryDetailSurface.shadowRadius, x: 0, y: EntryDetailSurface.shadowYOffset) } private var medicationText: String { @@ -463,7 +497,7 @@ private struct EntryDetailDeleteAction: View { var body: some View { Button("Löschen", role: .destructive, action: onDelete) .font(.system(.body, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.intensityStrong.color.opacity(0.84)) + .foregroundStyle(SymiColors.intensityStrong.color.opacity(0.78)) .frame(maxWidth: .infinity, minHeight: 54) .buttonStyle(EntryDetailDestructiveTextButtonStyle()) .padding(.top, 2) @@ -474,8 +508,8 @@ private struct EntryDetailDeleteAction: View { private struct EntryDetailDestructiveTextButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .opacity(configuration.isPressed ? 0.62 : 1) - .scaleEffect(configuration.isPressed ? 0.992 : 1) + .opacity(configuration.isPressed ? 0.68 : 1) + .scaleEffect(configuration.isPressed ? 0.98 : 1) .animation(.easeOut(duration: SymiAnimation.quickDuration), value: configuration.isPressed) } } @@ -521,11 +555,11 @@ private enum EntryDetailContextHierarchy { var color: Color { switch self { case .primary: - SymiColors.textPrimary.color + SymiColors.textPrimary.color.opacity(0.98) case .secondary: SymiColors.textPrimary.color.opacity(0.84) case .tertiary: - SymiColors.textSecondary.color + SymiColors.textSecondary.color.opacity(0.82) } } From dfc1db492aa2925e561ec8d845e5a167c0866ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:43:29 +0200 Subject: [PATCH 4/6] tokens --- .../Features/History/EpisodeDetailView.swift | 136 ++++++++++-------- .../InputFlow/Styling/SymiColors.swift | 5 + .../InputFlow/Styling/SymiSpacing.swift | 41 ++++++ .../InputFlow/Styling/SymiTypography.swift | 1 + 4 files changed, 124 insertions(+), 59 deletions(-) diff --git a/Symi/Sources/Features/History/EpisodeDetailView.swift b/Symi/Sources/Features/History/EpisodeDetailView.swift index ecc9796..bdb196f 100644 --- a/Symi/Sources/Features/History/EpisodeDetailView.swift +++ b/Symi/Sources/Features/History/EpisodeDetailView.swift @@ -30,7 +30,7 @@ struct EpisodeDetailView: View { var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: SymiSpacing.xxxl) { EntryDetailHeader( title: headerTitle, onBack: { dismiss() }, @@ -54,20 +54,20 @@ struct EpisodeDetailView: View { } } .padding(.horizontal, SymiSpacing.xxl) - .padding(.top, 18) - .padding(.bottom, 76) + .padding(.top, SymiSpacing.xl) + .padding(.bottom, SymiSpacing.entryDetailBottomPadding) } .background(SymiColors.warmBackground.color.ignoresSafeArea()) .overlay(alignment: .top) { LinearGradient( colors: [ SymiColors.warmBackground.color, - SymiColors.warmBackground.color.opacity(0) + SymiColors.warmBackground.color.opacity(SymiOpacity.entryDetailTopFadeEnd) ], startPoint: .top, endPoint: .bottom ) - .frame(height: 16) + .frame(height: SymiSize.entryDetailTopFadeHeight) .allowsHitTesting(false) } .navigationTitle("") @@ -169,29 +169,29 @@ private struct EntryDetailHeader: View { private enum EntryDetailSurface { static let cornerRadius: CGFloat = 28 - static let shadowColor = SymiColors.primaryPetrol.color.opacity(0.026) - static let shadowRadius: CGFloat = 22 + static let shadowColor = SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailShadow) + static let shadowRadius: CGFloat = 24 static let shadowYOffset: CGFloat = 10 - static let cardFill = Color(red: 1.0, green: 0.996, blue: 0.982) - static let highlight = Color.white.opacity(0.74) - static let iconFill = Color(red: 0.91, green: 0.95, blue: 0.90) + static let cardFill = SymiColors.entryDetailCard.color + static let highlight = SymiColors.onAccent.color.opacity(SymiOpacity.entryDetailHighlight) + static let iconFill = SymiColors.entryDetailIconFill.color } private struct EntryDetailHeroCard: View { let episode: EpisodeRecord var body: some View { - VStack(alignment: .leading, spacing: 25) { + VStack(alignment: .leading, spacing: SymiSpacing.entryDetailHeroSpacing) { HStack(alignment: .top, spacing: SymiSpacing.lg) { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: SymiSpacing.compact) { Text("Intensität") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(SymiColors.textSecondary.color) - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: SymiSpacing.zero) { Text(JournalEntryContext.intensityLabel(for: episode.intensity)) - .font(.system(size: 44, weight: .bold, design: .rounded)) - .foregroundStyle(SymiColors.textPrimary.color.opacity(0.98)) + .font(SymiTypography.entryDetailIntensityTitle) + .foregroundStyle(SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailPrimaryText)) .minimumScaleFactor(SymiTypography.compactScaleFactor) Text("\(episode.intensity)/10") @@ -212,10 +212,10 @@ private struct EntryDetailHeroCard: View { Text(intensityDescription) .font(.system(.body, design: .rounded).weight(.medium)) - .foregroundStyle(SymiColors.textPrimary.color.opacity(0.82)) + .foregroundStyle(SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailBodyText)) .fixedSize(horizontal: false, vertical: true) } - .padding(26) + .padding(SymiSpacing.entryDetailHeroPadding) .frame(maxWidth: .infinity, alignment: .leading) .background(EntryDetailSurface.cardFill, in: RoundedRectangle(cornerRadius: EntryDetailSurface.cornerRadius, style: .continuous)) .overlay( @@ -248,13 +248,16 @@ private struct EntryDetailFaceBadge: View { var body: some View { ZStack { Circle() - .fill(Color(red: 0.965, green: 0.918, blue: 0.835)) + .fill(SymiColors.entryDetailFaceFill.color) CalmFaceIcon() - .stroke(SymiColors.primaryPetrol.color.opacity(0.60), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) - .frame(width: 32, height: 32) + .stroke( + SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailFaceStroke), + style: StrokeStyle(lineWidth: SymiStroke.entryDetailFaceIcon, lineCap: .round, lineJoin: .round) + ) + .frame(width: SymiSize.entryDetailFaceIcon, height: SymiSize.entryDetailFaceIcon) } - .frame(width: 58, height: 58) + .frame(width: SymiSize.entryDetailFaceBadge, height: SymiSize.entryDetailFaceBadge) .accessibilityHidden(true) } } @@ -292,16 +295,16 @@ private struct EntryDetailProgressBar: View { ZStack(alignment: .leading) { Capsule() - .fill(Color.primary.opacity(0.075)) + .fill(Color.primary.opacity(SymiOpacity.entryDetailProgressTrack)) Capsule() .fill( LinearGradient( gradient: Gradient(stops: [ - .init(color: SymiColors.noteAmber.color.opacity(0.98), location: 0), - .init(color: Color(red: 0.86, green: 0.69, blue: 0.43), location: 0.42), - .init(color: Color(red: 0.70, green: 0.78, blue: 0.58), location: 0.68), - .init(color: SymiColors.sage.color.opacity(0.98), location: 1) + .init(color: SymiColors.noteAmber.color.opacity(SymiOpacity.entryDetailProgressStart), location: 0), + .init(color: SymiColors.entryDetailProgressWarmMid.color, location: 0.36), + .init(color: SymiColors.entryDetailProgressSageMid.color, location: 0.70), + .init(color: SymiColors.sage.color.opacity(SymiOpacity.entryDetailProgressEnd), location: 1) ]), startPoint: .leading, endPoint: .trailing @@ -309,14 +312,14 @@ private struct EntryDetailProgressBar: View { ) .overlay(alignment: .top) { Capsule() - .fill(Color.white.opacity(0.14)) - .frame(height: 2) - .padding(.horizontal, 1) + .fill(SymiColors.onAccent.color.opacity(SymiOpacity.entryDetailProgressHighlight)) + .frame(height: SymiSize.entryDetailProgressHighlightHeight) + .padding(.horizontal, SymiSize.accessibilityMarker) } .frame(width: proxy.size.width * clampedValue) } } - .frame(height: 11) + .frame(height: SymiSize.entryDetailProgressBarHeight) } } @@ -332,20 +335,25 @@ private struct EntryDetailContextCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 11) { + VStack(alignment: .leading, spacing: SymiSpacing.entryDetailContextRowSpacing) { ForEach(rows) { row in EntryDetailContextRow(row: row) } } - .padding(.horizontal, 22) - .padding(.vertical, 10) + .padding(.horizontal, SymiSpacing.entryDetailContextHorizontalPadding) + .padding(.vertical, SymiSpacing.entryDetailContextVerticalPadding) .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: 0, y: EntryDetailSurface.shadowYOffset) + .shadow( + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, + x: SymiShadow.cardXOffset, + y: EntryDetailSurface.shadowYOffset + ) } private var painLocationText: String { @@ -361,7 +369,7 @@ private struct EntryDetailContextRow: View { let row: EntryDetailContextRowModel var body: some View { - HStack(alignment: .center, spacing: 16) { + HStack(alignment: .center, spacing: SymiSpacing.lg) { EntryDetailContextIcon(systemImage: row.systemImage) Text(row.title) @@ -370,7 +378,7 @@ private struct EntryDetailContextRow: View { .lineLimit(row.hierarchy.lineLimit) .fixedSize(horizontal: false, vertical: true) } - .padding(.vertical, 18) + .padding(.vertical, SymiSpacing.entryDetailContextRowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } @@ -383,8 +391,8 @@ private struct EntryDetailContextIcon: View { Image(systemName: systemImage) .symbolRenderingMode(.monochrome) .font(.system(size: iconSize, weight: .medium, design: .rounded)) - .foregroundStyle(SymiColors.primaryPetrol.color.opacity(0.62)) - .frame(width: 32, height: 32) + .foregroundStyle(SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailIcon)) + .frame(width: SymiSize.entryDetailContextIcon, height: SymiSize.entryDetailContextIcon) .offset(iconOffset) .background(EntryDetailSurface.iconFill, in: Circle()) } @@ -416,24 +424,29 @@ private struct EntryDetailTriggerSection: View { let triggers: [String] var body: some View { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: SymiSpacing.entryDetailTriggerSectionSpacing) { Text("Mögliche Auslöser") .font(.system(.headline, design: .rounded).weight(.semibold)) .foregroundStyle(SymiColors.textPrimary.color) - LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { + LazyVGrid(columns: columns, alignment: .leading, spacing: SymiSpacing.entryDetailTriggerGridSpacing) { ForEach(visibleTriggers, id: \.self) { trigger in EntryDetailTriggerChip(title: trigger) } } - .padding(18) + .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: 0, y: EntryDetailSurface.shadowYOffset) + .shadow( + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, + x: SymiShadow.cardXOffset, + y: EntryDetailSurface.shadowYOffset + ) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -443,7 +456,7 @@ private struct EntryDetailTriggerSection: View { } private var columns: [GridItem] { - [GridItem(.adaptive(minimum: 118), spacing: 9, alignment: .leading)] + [GridItem(.adaptive(minimum: SymiSize.multiSelectGridMinWidth), spacing: SymiSpacing.entryDetailTriggerGridColumnSpacing, alignment: .leading)] } } @@ -453,12 +466,12 @@ private struct EntryDetailTriggerChip: View { var body: some View { Text(title) .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textPrimary.color.opacity(0.84)) + .foregroundStyle(SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailTriggerChipText)) .lineLimit(1) .minimumScaleFactor(SymiTypography.tightChipScaleFactor) - .padding(.horizontal, 15) - .padding(.vertical, 9) - .background(SymiColors.sage.color.opacity(0.22), in: Capsule()) + .padding(.horizontal, SymiSpacing.entryDetailTriggerChipHorizontalPadding) + .padding(.vertical, SymiSpacing.entryDetailTriggerChipVerticalPadding) + .background(SymiColors.sage.color.opacity(SymiOpacity.entryDetailTriggerChipFill), in: Capsule()) } } @@ -466,7 +479,7 @@ private struct EntryDetailMedicationCard: View { let episode: EpisodeRecord var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: SymiSpacing.xs) { Text("Eingenommen") .font(.system(.headline, design: .rounded).weight(.semibold)) .foregroundStyle(SymiColors.textPrimary.color) @@ -476,14 +489,19 @@ private struct EntryDetailMedicationCard: View { .foregroundStyle(SymiColors.textSecondary.color) .fixedSize(horizontal: false, vertical: true) } - .padding(22) + .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: 0, y: EntryDetailSurface.shadowYOffset) + .shadow( + color: EntryDetailSurface.shadowColor, + radius: EntryDetailSurface.shadowRadius, + x: SymiShadow.cardXOffset, + y: EntryDetailSurface.shadowYOffset + ) } private var medicationText: String { @@ -497,19 +515,19 @@ private struct EntryDetailDeleteAction: View { var body: some View { Button("Löschen", role: .destructive, action: onDelete) .font(.system(.body, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.intensityStrong.color.opacity(0.78)) - .frame(maxWidth: .infinity, minHeight: 54) + .foregroundStyle(SymiColors.intensityStrong.color.opacity(SymiOpacity.entryDetailDeleteText)) + .frame(maxWidth: .infinity, minHeight: SymiSize.entryDetailDeleteHeight) .buttonStyle(EntryDetailDestructiveTextButtonStyle()) - .padding(.top, 2) - .padding(.bottom, 38) + .padding(.top, SymiSpacing.micro) + .padding(.bottom, SymiSpacing.entryDetailDeleteBottomPadding) } } private struct EntryDetailDestructiveTextButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .opacity(configuration.isPressed ? 0.68 : 1) - .scaleEffect(configuration.isPressed ? 0.98 : 1) + .opacity(configuration.isPressed ? SymiOpacity.entryDetailDeletePressed : SymiOpacity.opaque) + .scaleEffect(configuration.isPressed ? SymiOpacity.entryDetailDeleteScale : SymiOpacity.opaque) .animation(.easeOut(duration: SymiAnimation.quickDuration), value: configuration.isPressed) } } @@ -555,11 +573,11 @@ private enum EntryDetailContextHierarchy { var color: Color { switch self { case .primary: - SymiColors.textPrimary.color.opacity(0.98) + SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailPrimaryText) case .secondary: - SymiColors.textPrimary.color.opacity(0.84) + SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailSecondaryText) case .tertiary: - SymiColors.textSecondary.color.opacity(0.82) + SymiColors.textSecondary.color.opacity(SymiOpacity.entryDetailTertiaryText) } } diff --git a/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift b/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift index cd79fb4..cf2c7d8 100644 --- a/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift +++ b/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift @@ -53,6 +53,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) diff --git a/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift b/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift index 78752e5..5cac0f1 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,13 @@ 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 entryDetailDeleteHeight: CGFloat = 54 static let emptyStateMinHeight: CGFloat = 360 static let defaultWindowWidth: CGFloat = 1280 static let defaultWindowHeight: CGFloat = 800 @@ -188,6 +210,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 +276,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) From 2b57e73b6e9389b0a70cab0bc3ca9f965bb54211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:54:07 +0200 Subject: [PATCH 5/6] V7 --- .../Features/History/EpisodeDetailView.swift | 222 +++++++++++++----- .../InputFlow/Styling/SymiColors.swift | 151 ++++++++++++ .../InputFlow/Styling/SymiSpacing.swift | 1 + 3 files changed, 311 insertions(+), 63 deletions(-) diff --git a/Symi/Sources/Features/History/EpisodeDetailView.swift b/Symi/Sources/Features/History/EpisodeDetailView.swift index bdb196f..2d32334 100644 --- a/Symi/Sources/Features/History/EpisodeDetailView.swift +++ b/Symi/Sources/Features/History/EpisodeDetailView.swift @@ -57,16 +57,9 @@ struct EpisodeDetailView: View { .padding(.top, SymiSpacing.xl) .padding(.bottom, SymiSpacing.entryDetailBottomPadding) } - .background(SymiColors.warmBackground.color.ignoresSafeArea()) + .background(ColorToken.Surface.appBackground.ignoresSafeArea()) .overlay(alignment: .top) { - LinearGradient( - colors: [ - SymiColors.warmBackground.color, - SymiColors.warmBackground.color.opacity(SymiOpacity.entryDetailTopFadeEnd) - ], - startPoint: .top, - endPoint: .bottom - ) + ColorToken.Surface.topFade .frame(height: SymiSize.entryDetailTopFadeHeight) .allowsHitTesting(false) } @@ -74,7 +67,7 @@ struct EpisodeDetailView: View { .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) - .tint(SymiColors.primaryPetrol.color) + .tint(ColorToken.Brand.primary) .task { await reload() } @@ -144,23 +137,23 @@ 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) .frame(maxWidth: .infinity, alignment: .center) Button("Bearbeiten", action: onEdit) .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.primaryPetrol.color) + .foregroundStyle(ColorToken.Brand.primary) .frame(minHeight: SymiSize.minInteractiveHeight) .buttonStyle(.plain) } @@ -169,50 +162,58 @@ private struct EntryDetailHeader: View { private enum EntryDetailSurface { static let cornerRadius: CGFloat = 28 - static let shadowColor = SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailShadow) + static let shadowColor = ColorToken.Shadow.card static let shadowRadius: CGFloat = 24 static let shadowYOffset: CGFloat = 10 - static let cardFill = SymiColors.entryDetailCard.color - static let highlight = SymiColors.onAccent.color.opacity(SymiOpacity.entryDetailHighlight) - static let iconFill = SymiColors.entryDetailIconFill.color + 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 painLevel: PainLevel { + PainLevel(intensity: episode.intensity) + } + + private var painToken: PainToken { + ColorToken.Pain.token(for: painLevel) + } + var body: some View { 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(SymiColors.textSecondary.color) + .foregroundStyle(ColorToken.Text.label) VStack(alignment: .leading, spacing: SymiSpacing.zero) { Text(JournalEntryContext.intensityLabel(for: episode.intensity)) .font(SymiTypography.entryDetailIntensityTitle) - .foregroundStyle(SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailPrimaryText)) + .foregroundStyle(painToken.emphasizedText) .minimumScaleFactor(SymiTypography.compactScaleFactor) Text("\(episode.intensity)/10") .font(.system(.title2, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textSecondary.color) + .foregroundStyle(ColorToken.Text.label) .monospacedDigit() } } Spacer(minLength: SymiSpacing.md) - EntryDetailFaceBadge() + EntryDetailFaceBadge(painLevel: painLevel, painToken: painToken) } - EntryDetailProgressBar(value: Double(episode.intensity) / 10) + 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(SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailBodyText)) + .foregroundStyle(painToken.descriptionText) .fixedSize(horizontal: false, vertical: true) } .padding(SymiSpacing.entryDetailHeroPadding) @@ -245,14 +246,17 @@ private struct EntryDetailHeroCard: View { } private struct EntryDetailFaceBadge: View { + let painLevel: PainLevel + let painToken: PainToken + var body: some View { ZStack { Circle() - .fill(SymiColors.entryDetailFaceFill.color) + .fill(painToken.faceBackground) - CalmFaceIcon() + CalmFaceIcon(painLevel: painLevel) .stroke( - SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailFaceStroke), + painToken.icon, style: StrokeStyle(lineWidth: SymiStroke.entryDetailFaceIcon, lineCap: .round, lineJoin: .round) ) .frame(width: SymiSize.entryDetailFaceIcon, height: SymiSize.entryDetailFaceIcon) @@ -263,6 +267,8 @@ private struct EntryDetailFaceBadge: View { } private struct CalmFaceIcon: Shape { + let painLevel: PainLevel + func path(in rect: CGRect) -> Path { var path = Path() let width = rect.width @@ -270,24 +276,87 @@ private struct CalmFaceIcon: Shape { 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: height * 0.40)) - path.addLine(to: CGPoint(x: width * 0.34, y: height * 0.42)) + 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: height * 0.40)) - path.addLine(to: CGPoint(x: width * 0.66, y: height * 0.42)) + 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: height * 0.63)) + path.move(to: CGPoint(x: width * 0.36, y: mouthY * height)) path.addQuadCurve( - to: CGPoint(x: width * 0.64, y: height * 0.63), - control: CGPoint(x: width * 0.50, y: height * 0.67) + 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 EntryDetailProgressBar: View { let value: Double + let painToken: PainToken var body: some View { GeometryReader { proxy in @@ -295,24 +364,13 @@ private struct EntryDetailProgressBar: View { ZStack(alignment: .leading) { Capsule() - .fill(Color.primary.opacity(SymiOpacity.entryDetailProgressTrack)) + .fill(ColorToken.Surface.progressTrack) Capsule() - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: SymiColors.noteAmber.color.opacity(SymiOpacity.entryDetailProgressStart), location: 0), - .init(color: SymiColors.entryDetailProgressWarmMid.color, location: 0.36), - .init(color: SymiColors.entryDetailProgressSageMid.color, location: 0.70), - .init(color: SymiColors.sage.color.opacity(SymiOpacity.entryDetailProgressEnd), location: 1) - ]), - startPoint: .leading, - endPoint: .trailing - ) - ) + .fill(painToken.progressGradient) .overlay(alignment: .top) { Capsule() - .fill(SymiColors.onAccent.color.opacity(SymiOpacity.entryDetailProgressHighlight)) + .fill(ColorToken.Surface.progressHighlight) .frame(height: SymiSize.entryDetailProgressHighlightHeight) .padding(.horizontal, SymiSize.accessibilityMarker) } @@ -326,11 +384,20 @@ private struct EntryDetailProgressBar: View { 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), - EntryDetailContextRowModel(systemImage: "note.text", title: noteText, hierarchy: .tertiary) + EntryDetailContextRowModel( + systemImage: "brain.head.profile", + title: painLocationText, + hierarchy: .secondary, + category: .pain(painLevel) + ), + EntryDetailContextRowModel(systemImage: "note.text", title: noteText, hierarchy: .tertiary, category: .note) ] } @@ -370,7 +437,7 @@ private struct EntryDetailContextRow: View { var body: some View { HStack(alignment: .center, spacing: SymiSpacing.lg) { - EntryDetailContextIcon(systemImage: row.systemImage) + EntryDetailContextIcon(systemImage: row.systemImage, category: row.category) Text(row.title) .font(row.hierarchy.font) @@ -386,12 +453,13 @@ private struct EntryDetailContextRow: View { private struct EntryDetailContextIcon: View { let systemImage: String + let category: EntryDetailContextCategory var body: some View { Image(systemName: systemImage) .symbolRenderingMode(.monochrome) .font(.system(size: iconSize, weight: .medium, design: .rounded)) - .foregroundStyle(SymiColors.primaryPetrol.color.opacity(SymiOpacity.entryDetailIcon)) + .foregroundStyle(category.iconColor) .frame(width: SymiSize.entryDetailContextIcon, height: SymiSize.entryDetailContextIcon) .offset(iconOffset) .background(EntryDetailSurface.iconFill, in: Circle()) @@ -427,9 +495,9 @@ private struct EntryDetailTriggerSection: View { VStack(alignment: .leading, spacing: SymiSpacing.entryDetailTriggerSectionSpacing) { Text("Mögliche Auslöser") .font(.system(.headline, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textPrimary.color) + .foregroundStyle(ColorToken.Text.onSurface) - LazyVGrid(columns: columns, alignment: .leading, spacing: SymiSpacing.entryDetailTriggerGridSpacing) { + LazyVGrid(columns: columns, alignment: chipAlignment, spacing: SymiSpacing.entryDetailTriggerGridSpacing) { ForEach(visibleTriggers, id: \.self) { trigger in EntryDetailTriggerChip(title: trigger) } @@ -455,8 +523,18 @@ private struct EntryDetailTriggerSection: View { triggers.map(\.trimmed).filter { !$0.isEmpty } } + private var chipAlignment: HorizontalAlignment { + visibleTriggers.count == 1 ? .center : .leading + } + private var columns: [GridItem] { - [GridItem(.adaptive(minimum: SymiSize.multiSelectGridMinWidth), spacing: SymiSpacing.entryDetailTriggerGridColumnSpacing, alignment: .leading)] + [ + GridItem( + .adaptive(minimum: SymiSize.entryDetailTriggerGridMinWidth), + spacing: SymiSpacing.entryDetailTriggerGridColumnSpacing, + alignment: .leading + ) + ] } } @@ -466,12 +544,12 @@ private struct EntryDetailTriggerChip: View { var body: some View { Text(title) .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailTriggerChipText)) + .foregroundStyle(ColorToken.Trigger.foreground) .lineLimit(1) .minimumScaleFactor(SymiTypography.tightChipScaleFactor) .padding(.horizontal, SymiSpacing.entryDetailTriggerChipHorizontalPadding) .padding(.vertical, SymiSpacing.entryDetailTriggerChipVerticalPadding) - .background(SymiColors.sage.color.opacity(SymiOpacity.entryDetailTriggerChipFill), in: Capsule()) + .background(ColorToken.Trigger.background, in: Capsule()) } } @@ -482,11 +560,11 @@ private struct EntryDetailMedicationCard: View { VStack(alignment: .leading, spacing: SymiSpacing.xs) { Text("Eingenommen") .font(.system(.headline, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.textPrimary.color) + .foregroundStyle(ColorToken.Medication.foreground) Text(medicationText) .font(.system(.body, design: .rounded).weight(.medium)) - .foregroundStyle(SymiColors.textSecondary.color) + .foregroundStyle(ColorToken.Text.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(SymiSpacing.entryDetailMedicationCardPadding) @@ -515,7 +593,7 @@ private struct EntryDetailDeleteAction: View { var body: some View { Button("Löschen", role: .destructive, action: onDelete) .font(.system(.body, design: .rounded).weight(.semibold)) - .foregroundStyle(SymiColors.intensityStrong.color.opacity(SymiOpacity.entryDetailDeleteText)) + .foregroundStyle(ColorToken.Text.destructive) .frame(maxWidth: .infinity, minHeight: SymiSize.entryDetailDeleteHeight) .buttonStyle(EntryDetailDestructiveTextButtonStyle()) .padding(.top, SymiSpacing.micro) @@ -552,6 +630,7 @@ private struct EntryDetailContextRowModel: Identifiable { let systemImage: String let title: String var hierarchy: EntryDetailContextHierarchy = .primary + var category: EntryDetailContextCategory = .neutral } private enum EntryDetailContextHierarchy { @@ -573,11 +652,11 @@ private enum EntryDetailContextHierarchy { var color: Color { switch self { case .primary: - SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailPrimaryText) + ColorToken.Text.primary case .secondary: - SymiColors.textPrimary.color.opacity(SymiOpacity.entryDetailSecondaryText) + ColorToken.Text.secondary case .tertiary: - SymiColors.textSecondary.color.opacity(SymiOpacity.entryDetailTertiaryText) + ColorToken.Text.tertiary } } @@ -591,6 +670,23 @@ private enum EntryDetailContextHierarchy { } } +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 } diff --git a/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift b/Symi/Sources/Features/InputFlow/Styling/SymiColors.swift index cf2c7d8..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) @@ -93,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 5cac0f1..a78cbbc 100644 --- a/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift +++ b/Symi/Sources/Features/InputFlow/Styling/SymiSpacing.swift @@ -186,6 +186,7 @@ nonisolated enum SymiSize { 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 From 8e558579375e4fae7bebad22c7fc9ae042aac6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:56:11 +0200 Subject: [PATCH 6/6] final --- .../Features/History/EpisodeDetailView.swift | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/Symi/Sources/Features/History/EpisodeDetailView.swift b/Symi/Sources/Features/History/EpisodeDetailView.swift index 2d32334..67a3cf1 100644 --- a/Symi/Sources/Features/History/EpisodeDetailView.swift +++ b/Symi/Sources/Features/History/EpisodeDetailView.swift @@ -29,33 +29,37 @@ struct EpisodeDetailView: View { } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: SymiSpacing.xxxl) { - 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) - EntryDetailContextCard(episode: episode) + EntryDetailContextCard(episode: episode) - if episode.hasVisibleTriggers { - EntryDetailTriggerSection(triggers: episode.triggers) - } + if episode.hasVisibleTriggers { + EntryDetailTriggerSection(triggers: episode.triggers) + } - EntryDetailMedicationCard(episode: episode) + EntryDetailMedicationCard(episode: episode) - EntryDetailDeleteAction(onDelete: { isShowingDeleteConfirmation = true }) - } else { - EntryDetailLoadingState(isLoading: isLoading) + 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.entryDetailBottomPadding) } .background(ColorToken.Surface.appBackground.ignoresSafeArea()) .overlay(alignment: .top) {