2026-07-07 23:23:54 +03:00
|
|
|
import SwiftUI
|
|
|
|
|
import Charts
|
|
|
|
|
|
|
|
|
|
// Analytics area: Overview · Daily · Weekly · Monthly · 12-Month · Exercises.
|
|
|
|
|
// Reads from AnalyticsService (which reconciles reports elsewhere). Status is
|
|
|
|
|
// conveyed by BOTH color and a glyph (never color alone). Includes empty/partial
|
|
|
|
|
// states and an informational, non-medical disclaimer.
|
|
|
|
|
//
|
|
|
|
|
// NOTE: written without an available build in this environment — verify on device.
|
|
|
|
|
struct AnalyticsView: View {
|
|
|
|
|
@Environment(\.colorScheme) private var cs
|
|
|
|
|
@ObservedObject private var service = AnalyticsService.shared
|
|
|
|
|
@State private var area: Area = .overview
|
|
|
|
|
@State private var selectedExercise: String?
|
|
|
|
|
|
2026-07-07 23:36:45 +03:00
|
|
|
/// Optional deep link (from a report notification) picks the initial area.
|
|
|
|
|
init(initialLink: AnalyticsDeepLink? = nil) {
|
|
|
|
|
switch initialLink {
|
|
|
|
|
case .weekly: _area = State(initialValue: .weekly)
|
|
|
|
|
case .monthly: _area = State(initialValue: .monthly)
|
|
|
|
|
default: break // keep default .overview
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 23:23:54 +03:00
|
|
|
enum Area: String, CaseIterable, Identifiable {
|
|
|
|
|
case overview = "Overview", daily = "Daily", weekly = "Weekly"
|
|
|
|
|
case monthly = "Monthly", trends = "12-Month", exercises = "Exercises"
|
|
|
|
|
var id: String { rawValue }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var hasAnyReports: Bool {
|
|
|
|
|
!service.weeklyReports().isEmpty || !service.monthlyReports().isEmpty
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
header
|
|
|
|
|
areaSelector
|
|
|
|
|
Divider().background(AppColors.border(cs))
|
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
content
|
|
|
|
|
disclaimer
|
|
|
|
|
}
|
|
|
|
|
.padding(16)
|
|
|
|
|
.padding(.bottom, 40)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var header: some View {
|
|
|
|
|
Text("Analytics")
|
|
|
|
|
.font(AppFonts.sans(20, weight: .bold))
|
|
|
|
|
.foregroundColor(AppColors.text(cs))
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var areaSelector: some View {
|
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
ForEach(Area.allCases) { a in
|
|
|
|
|
let on = a == area
|
|
|
|
|
Text(a.rawValue)
|
|
|
|
|
.font(AppFonts.sans(12, weight: .semibold))
|
|
|
|
|
.foregroundColor(on ? .white : AppColors.text2(cs))
|
|
|
|
|
.padding(.horizontal, 12).padding(.vertical, 7)
|
|
|
|
|
.background(on ? AppColors.accent : AppColors.surface2(cs))
|
|
|
|
|
.clipShape(Capsule())
|
|
|
|
|
.contentShape(Capsule())
|
|
|
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { area = a } }
|
|
|
|
|
.accessibilityLabel(a.rawValue)
|
|
|
|
|
.accessibilityAddTraits(on ? [.isButton, .isSelected] : [.isButton])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ViewBuilder private var content: some View {
|
|
|
|
|
if !hasAnyReports && area != .daily {
|
|
|
|
|
emptyState
|
|
|
|
|
} else {
|
|
|
|
|
switch area {
|
|
|
|
|
case .overview: overview
|
|
|
|
|
case .daily: dailySection
|
|
|
|
|
case .weekly: weeklySection
|
|
|
|
|
case .monthly: monthlySection
|
|
|
|
|
case .trends: trendsSection
|
|
|
|
|
case .exercises: exercisesSection
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Empty state
|
|
|
|
|
|
|
|
|
|
private var emptyState: some View {
|
|
|
|
|
VStack(spacing: 8) {
|
|
|
|
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
|
|
|
|
.font(.system(size: 30)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
Text("No reports yet")
|
|
|
|
|
.font(AppFonts.sans(15, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
|
|
|
|
Text("As each week and month completes, an immutable summary appears here.")
|
|
|
|
|
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
.multilineTextAlignment(.center)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity).padding(.vertical, 60)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Overview
|
|
|
|
|
|
|
|
|
|
private var overview: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
if let w = service.weeklyReports().first {
|
|
|
|
|
sectionTitle("Latest week")
|
|
|
|
|
weeklyCard(w)
|
|
|
|
|
}
|
|
|
|
|
if let m = service.monthlyReports().first {
|
|
|
|
|
sectionTitle("Latest month")
|
|
|
|
|
monthlyCard(m)
|
|
|
|
|
}
|
|
|
|
|
sectionTitle("12-month direction")
|
|
|
|
|
HStack {
|
|
|
|
|
trendBadge(service.monthlyTrendClassification().classification)
|
|
|
|
|
Spacer()
|
|
|
|
|
Text("\(service.monthlyReports().count) mo · \(service.weeklyReports().count) wk")
|
|
|
|
|
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
}
|
|
|
|
|
.padding(12).cardBG(cs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Daily
|
|
|
|
|
|
|
|
|
|
private var dailySection: some View {
|
|
|
|
|
let cmp = service.dailyComparison(on: Date())
|
|
|
|
|
return VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
if cmp.restDay {
|
|
|
|
|
infoCard("Rest day", "No workout logged today — nothing to compare.")
|
|
|
|
|
}
|
|
|
|
|
comparisonGroup("vs. yesterday", cmp.vsPreviousDay)
|
|
|
|
|
comparisonGroup("vs. same day last week", cmp.vsSameWeekdayLastWeek)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func comparisonGroup(_ title: String, _ comps: [ExerciseComparison]) -> some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
sectionTitle(title)
|
|
|
|
|
if comps.isEmpty {
|
|
|
|
|
infoCard("Nothing to compare", "No matching exercises for this comparison.")
|
|
|
|
|
} else {
|
|
|
|
|
ForEach(comps, id: \.identity) { c in
|
|
|
|
|
HStack {
|
|
|
|
|
Text(c.identity.capitalized)
|
|
|
|
|
.font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
|
|
|
|
|
if c.isNew {
|
|
|
|
|
Text("NEW").font(AppFonts.mono(8, weight: .bold))
|
|
|
|
|
.foregroundColor(AppColors.blue)
|
|
|
|
|
.padding(.horizontal, 5).padding(.vertical, 2)
|
|
|
|
|
.background(AppColors.blueSoft).clipShape(Capsule())
|
|
|
|
|
}
|
|
|
|
|
Spacer()
|
|
|
|
|
deltaBadge(c.volume, unit: "kg")
|
|
|
|
|
}
|
|
|
|
|
.padding(12).cardBG(cs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Weekly / Monthly
|
|
|
|
|
|
|
|
|
|
private var weeklySection: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
ForEach(service.weeklyReports()) { weeklyCard($0) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var monthlySection: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
ForEach(service.monthlyReports()) { monthlyCard($0) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func weeklyCard(_ r: WeeklyReport) -> some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
HStack {
|
|
|
|
|
Text("Week of \(r.weekStartKey)")
|
|
|
|
|
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
|
|
|
Spacer()
|
|
|
|
|
trendBadge(r.progression)
|
|
|
|
|
}
|
|
|
|
|
summaryRow(r.summary)
|
2026-07-08 10:39:00 +03:00
|
|
|
healthRow(r.summary)
|
2026-07-07 23:23:54 +03:00
|
|
|
deltaChips(r.vsPreviousWeek)
|
|
|
|
|
}
|
|
|
|
|
.padding(14).cardBG(cs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func monthlyCard(_ r: MonthlyReport) -> some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
HStack {
|
|
|
|
|
Text(r.monthKey)
|
|
|
|
|
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
|
|
|
Spacer()
|
|
|
|
|
trendBadge(r.classification)
|
|
|
|
|
}
|
|
|
|
|
summaryRow(r.summary)
|
2026-07-08 10:39:00 +03:00
|
|
|
healthRow(r.summary)
|
2026-07-07 23:23:54 +03:00
|
|
|
deltaChips(r.vsPreviousMonth)
|
|
|
|
|
}
|
|
|
|
|
.padding(14).cardBG(cs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func summaryRow(_ s: PeriodSummary) -> some View {
|
|
|
|
|
HStack(spacing: 14) {
|
|
|
|
|
stat("\(s.sessions)", "sessions")
|
|
|
|
|
stat("\(s.sets)", "sets")
|
|
|
|
|
stat("\(Int(s.volume))", "kg")
|
|
|
|
|
stat("\(Int(s.consistency * 100))%", "consistent")
|
|
|
|
|
if s.missed > 0 { stat("\(s.missed)", "missed") }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 10:39:00 +03:00
|
|
|
/// Available HealthKit trends for the period (shown only when present).
|
|
|
|
|
@ViewBuilder private func healthRow(_ s: PeriodSummary) -> some View {
|
|
|
|
|
if s.avgSteps != nil || s.avgActiveCalories != nil || s.avgRestingHR != nil {
|
|
|
|
|
HStack(spacing: 14) {
|
|
|
|
|
if let v = s.avgSteps { stat("\(v)", "avg steps") }
|
|
|
|
|
if let v = s.avgActiveCalories { stat("\(v)", "avg kcal") }
|
|
|
|
|
if let v = s.avgRestingHR { stat("\(v)", "avg bpm") }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 23:23:54 +03:00
|
|
|
private func deltaChips(_ deltas: [String: MetricDelta]) -> some View {
|
|
|
|
|
let order = ["volume", "sessions", "sets", "duration", "consistency"]
|
|
|
|
|
return FlowRow(order.compactMap { key -> (String, MetricDelta)? in
|
|
|
|
|
guard let d = deltas[key], d.meaningful, d.percent != nil else { return nil }
|
|
|
|
|
return (key, d)
|
|
|
|
|
}) { pair in
|
|
|
|
|
HStack(spacing: 4) {
|
|
|
|
|
Text(pair.0).font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
deltaBadge(pair.1, unit: "")
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
|
|
|
.background(AppColors.surface2(cs)).clipShape(Capsule())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - 12-Month trends
|
|
|
|
|
|
|
|
|
|
private var trendsSection: some View {
|
|
|
|
|
let series = service.monthlyVolumeTrend()
|
|
|
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
HStack {
|
|
|
|
|
sectionTitle("Monthly volume")
|
|
|
|
|
Spacer()
|
|
|
|
|
trendBadge(service.monthlyTrendClassification().classification)
|
|
|
|
|
}
|
|
|
|
|
if series.isEmpty {
|
|
|
|
|
infoCard("Not enough data", "A few completed months are needed to chart a trend.")
|
|
|
|
|
} else {
|
|
|
|
|
Chart(series) { point in
|
|
|
|
|
BarMark(
|
|
|
|
|
x: .value("Month", point.monthKey),
|
|
|
|
|
y: .value("Volume", point.volume)
|
|
|
|
|
)
|
|
|
|
|
.foregroundStyle(AppColors.accent)
|
|
|
|
|
}
|
|
|
|
|
.frame(height: 200)
|
|
|
|
|
.padding(12).cardBG(cs)
|
|
|
|
|
.accessibilityLabel("Monthly training volume for the last year")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Per-exercise
|
|
|
|
|
|
|
|
|
|
private var exercisesSection: some View {
|
|
|
|
|
let names = service.knownExerciseIdentities()
|
|
|
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
if names.isEmpty {
|
|
|
|
|
infoCard("No exercise history", "Logged exercises will appear here over time.")
|
|
|
|
|
} else {
|
|
|
|
|
Menu {
|
|
|
|
|
ForEach(names, id: \.self) { n in
|
|
|
|
|
Button(n.capitalized) { selectedExercise = n }
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
HStack {
|
|
|
|
|
Text((selectedExercise ?? names.first ?? "").capitalized)
|
|
|
|
|
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
|
|
|
Image(systemName: "chevron.down").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
Spacer()
|
|
|
|
|
}
|
|
|
|
|
.padding(12).cardBG(cs)
|
|
|
|
|
}
|
|
|
|
|
let id = selectedExercise ?? names.first ?? ""
|
|
|
|
|
let history = service.exerciseHistory(identity: id)
|
|
|
|
|
if history.count < 2 {
|
|
|
|
|
infoCard("Not enough history", "Log this exercise on more days to see a trend.")
|
|
|
|
|
} else {
|
|
|
|
|
Chart(history) { point in
|
|
|
|
|
LineMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
|
|
|
|
|
.foregroundStyle(AppColors.accent)
|
|
|
|
|
PointMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
|
|
|
|
|
.foregroundStyle(AppColors.accent)
|
|
|
|
|
}
|
|
|
|
|
.frame(height: 200)
|
|
|
|
|
.padding(12).cardBG(cs)
|
|
|
|
|
.accessibilityLabel("Volume history for \(id)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Reusable bits
|
|
|
|
|
|
|
|
|
|
private func sectionTitle(_ t: String) -> some View {
|
|
|
|
|
Text(t.uppercased())
|
|
|
|
|
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func stat(_ value: String, _ label: String) -> some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
|
|
|
Text(value).font(AppFonts.sans(15, weight: .bold)).foregroundColor(AppColors.text(cs))
|
|
|
|
|
Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func infoCard(_ title: String, _ body: String) -> some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
|
|
|
Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
|
|
|
|
Text(body).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(12).cardBG(cs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Direction shown by glyph + sign + color (glyph works without color).
|
|
|
|
|
private func deltaBadge(_ d: MetricDelta, unit: String) -> some View {
|
|
|
|
|
let (glyph, color): (String, Color) = {
|
|
|
|
|
switch d.direction {
|
|
|
|
|
case .up: return ("arrow.up.right", AppColors.green)
|
|
|
|
|
case .down: return ("arrow.down.right", AppColors.red)
|
|
|
|
|
case .flat: return ("equal", AppColors.text3(cs))
|
|
|
|
|
case .none: return ("minus", AppColors.text3(cs))
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
let text: String = {
|
|
|
|
|
if let p = d.percent { return String(format: "%+.0f%%", p) }
|
|
|
|
|
if let a = d.absolute {
|
|
|
|
|
let u = unit.isEmpty ? "" : " " + unit
|
|
|
|
|
return String(format: "%+.0f", a) + u
|
|
|
|
|
}
|
|
|
|
|
return "—"
|
|
|
|
|
}()
|
|
|
|
|
return HStack(spacing: 3) {
|
|
|
|
|
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
|
|
|
|
|
Text(text).font(AppFonts.mono(11, weight: .semibold))
|
|
|
|
|
}
|
|
|
|
|
.foregroundColor(d.meaningful ? color : AppColors.text3(cs))
|
|
|
|
|
.accessibilityElement(children: .combine)
|
|
|
|
|
.accessibilityLabel("\(d.direction.rawValue) \(text)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func trendBadge(_ t: TrendClassification) -> some View {
|
|
|
|
|
let (glyph, color, label): (String, Color, String) = {
|
|
|
|
|
switch t {
|
|
|
|
|
case .improving: return ("arrow.up.right", AppColors.green, "Improving")
|
|
|
|
|
case .declining: return ("arrow.down.right", AppColors.red, "Declining")
|
|
|
|
|
case .plateau: return ("equal", AppColors.text2(cs), "Plateau")
|
|
|
|
|
case .insufficientData: return ("minus", AppColors.text3(cs), "Not enough data")
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
return HStack(spacing: 4) {
|
|
|
|
|
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
|
|
|
|
|
Text(label).font(AppFonts.mono(9, weight: .bold))
|
|
|
|
|
}
|
|
|
|
|
.foregroundColor(color)
|
|
|
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
|
|
|
.background(color.opacity(0.12)).clipShape(Capsule())
|
|
|
|
|
.accessibilityElement(children: .combine)
|
|
|
|
|
.accessibilityLabel("Trend: \(label)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var disclaimer: some View {
|
|
|
|
|
Text(AnalyticsEngine.disclaimer)
|
|
|
|
|
.font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(.top, 8)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A minimal wrapping row (chips flow to new lines).
|
|
|
|
|
private struct FlowRow<Data: RandomAccessCollection, Content: View>: View {
|
|
|
|
|
let items: Data
|
|
|
|
|
let content: (Data.Element) -> Content
|
|
|
|
|
init(_ items: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) {
|
|
|
|
|
self.items = items; self.content = content
|
|
|
|
|
}
|
|
|
|
|
var body: some View {
|
|
|
|
|
// Simple 2-per-row grid is enough for the small delta-chip set.
|
|
|
|
|
let arr = Array(items)
|
|
|
|
|
return VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
ForEach(Array(stride(from: 0, to: arr.count, by: 2)), id: \.self) { i in
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
content(arr[i])
|
|
|
|
|
if i + 1 < arr.count { content(arr[i + 1]) }
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension View {
|
|
|
|
|
/// Standard card background used across the analytics screens.
|
|
|
|
|
func cardBG(_ cs: ColorScheme) -> some View {
|
|
|
|
|
self.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.background(AppColors.surface(cs))
|
|
|
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
|
|
|
}
|
|
|
|
|
}
|