Add rest timer with toggle — auto-starts after each set when enabled
Floating banner shows circular progress + MM:SS countdown with skip button. Haptic + system sound fires when timer reaches zero. Duration and on/off toggle live in Settings → Rest Timer sheet. Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
@@ -178,6 +178,9 @@ class WorkoutViewModel: ObservableObject {
|
||||
@Published var activeSections: [WorkoutSection]
|
||||
@Published var workoutDates: [String] = []
|
||||
@Published var restTimerSeconds: Int = 60
|
||||
@Published var restTimerEnabled: Bool = false
|
||||
@Published var restCountdown: Int = 0
|
||||
@Published var restTimerTotal: Int = 0
|
||||
@Published var sessionStartDate: Date? = nil
|
||||
|
||||
var streakDays: Int {
|
||||
@@ -404,12 +407,14 @@ class WorkoutViewModel: ObservableObject {
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId }),
|
||||
let setIdx = activeSections[si].exercises[ei].sets.firstIndex(where: { $0.id == setId })
|
||||
else { return }
|
||||
let wasMarkedDone = !activeSections[si].exercises[ei].sets[setIdx].isDone
|
||||
activeSections[si].exercises[ei].sets[setIdx].isDone.toggle()
|
||||
if sessionStartDate == nil, activeSections[si].exercises[ei].sets[setIdx].isDone {
|
||||
sessionStartDate = Date()
|
||||
}
|
||||
syncBack()
|
||||
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||
if wasMarkedDone && restTimerEnabled { startRestTimer() }
|
||||
}
|
||||
|
||||
func addSet(sectionId: UUID, to exerciseId: UUID) {
|
||||
@@ -495,6 +500,18 @@ class WorkoutViewModel: ObservableObject {
|
||||
syncBack()
|
||||
}
|
||||
|
||||
// MARK: - Rest Timer
|
||||
|
||||
func startRestTimer() {
|
||||
restTimerTotal = restTimerSeconds
|
||||
restCountdown = restTimerSeconds
|
||||
}
|
||||
|
||||
func stopRestTimer() {
|
||||
restCountdown = 0
|
||||
restTimerTotal = 0
|
||||
}
|
||||
|
||||
private func syncBack() {
|
||||
guard let idx = programs.firstIndex(where: { $0.id == activeProgramId }) else { return }
|
||||
programs[idx].sections = activeSections
|
||||
|
||||
@@ -39,7 +39,7 @@ struct SettingsView: View {
|
||||
@State private var showAbout = false
|
||||
|
||||
private var appearanceLabel: String { ["System", "Light", "Dark"][appearanceMode] }
|
||||
private var restLabel: String { "\(workoutVM.restTimerSeconds)s" }
|
||||
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
|
||||
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
|
||||
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
|
||||
|
||||
@@ -166,7 +166,7 @@ struct SettingsView: View {
|
||||
.sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds).presentationDetents([.height(340)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showAbout) { AboutSheet().presentationDetents([.height(340)]).presentationDragIndicator(.visible) }
|
||||
@@ -825,22 +825,47 @@ private struct StepperRow: View {
|
||||
// MARK: - Rest Timer Sheet
|
||||
private struct RestTimerSheet: View {
|
||||
@Binding var seconds: Int
|
||||
@Binding var enabled: Bool
|
||||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||||
private let options = [30, 45, 60, 90, 120, 180]
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
SheetHeader(title: "Rest Timer") { dismiss() }
|
||||
Text("\(seconds)s").font(AppFonts.mono(52, weight: .bold)).foregroundColor(AppColors.accent).padding(.bottom, 20)
|
||||
|
||||
// Toggle
|
||||
HStack {
|
||||
Text("Auto-start after each set")
|
||||
.font(AppFonts.sans(14))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Toggle("", isOn: $enabled)
|
||||
.labelsHidden()
|
||||
.tint(AppColors.accent)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// Duration picker (dimmed when disabled)
|
||||
Text(seconds >= 60 ? String(format: "%d:%02d", seconds / 60, seconds % 60) : "\(seconds)s")
|
||||
.font(AppFonts.mono(52, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.opacity(enabled ? 1 : 0.35)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) {
|
||||
ForEach(options, id: \.self) { opt in
|
||||
Button { withAnimation(KisaniSpring.micro) { seconds = opt } } label: {
|
||||
Text("\(opt)s").font(AppFonts.mono(14, weight: .bold))
|
||||
Text(opt >= 60 ? String(format: "%d:%02d", opt / 60, opt % 60) : "\(opt)s")
|
||||
.font(AppFonts.mono(14, weight: .bold))
|
||||
.foregroundColor(seconds == opt ? AppColors.accent : AppColors.text2(cs))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 13)
|
||||
.background(seconds == opt ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(seconds == opt ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(enabled ? 1 : 0.35)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import SwiftUI
|
||||
import AudioToolbox
|
||||
|
||||
// MARK: - Rest Timer Banner
|
||||
private struct RestTimerBanner: View {
|
||||
let countdown: Int
|
||||
let total: Int
|
||||
let onSkip: () -> Void
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
private var progress: Double { total > 0 ? Double(countdown) / Double(total) : 0 }
|
||||
|
||||
private var timeLabel: String {
|
||||
countdown >= 60
|
||||
? String(format: "%d:%02d", countdown / 60, countdown % 60)
|
||||
: "\(countdown)s"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(AppColors.accent.opacity(0.18), lineWidth: 3)
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(AppColors.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 1), value: progress)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("REST").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
Text(timeLabel).font(AppFonts.mono(22, weight: .bold)).foregroundColor(AppColors.accent)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onSkip) {
|
||||
Text("Skip")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(AppColors.accentSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(PressButtonStyle(scale: 0.94))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18).stroke(AppColors.accent.opacity(0.2), lineWidth: 1))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout View
|
||||
struct WorkoutView: View {
|
||||
@@ -6,6 +61,8 @@ struct WorkoutView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@State private var showManage = false
|
||||
@State private var showAddSection = false
|
||||
@State private var isReordering = false
|
||||
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
private var headerDate: String {
|
||||
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"
|
||||
@@ -16,12 +73,20 @@ struct WorkoutView: View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AppColors.background(cs).ignoresSafeArea()
|
||||
|
||||
if isReordering {
|
||||
Color.black.opacity(0.001)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
withAnimation(KisaniSpring.snappy) { isReordering = false }
|
||||
}
|
||||
}
|
||||
|
||||
List {
|
||||
// ── Header ──
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(vm.workoutEmoji).font(.system(size: 15))
|
||||
ProgramIcon(name: vm.workoutEmoji, size: 14, color: AppColors.accent)
|
||||
Text(vm.workoutTitle)
|
||||
.font(AppFonts.sans(15, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
@@ -73,6 +138,10 @@ struct WorkoutView: View {
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
|
||||
.onLongPressGesture(minimumDuration: 0.4) {
|
||||
withAnimation(KisaniSpring.snappy) { isReordering = true }
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
withAnimation { vm.deleteExercise(sectionId: section.id, exerciseId: ex.id) }
|
||||
@@ -135,7 +204,7 @@ struct WorkoutView: View {
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.environment(\.editMode, .constant(isReordering ? .active : .inactive))
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
@@ -151,6 +220,29 @@ struct WorkoutView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if isReordering {
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { isReordering = false }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
Text("Done Reordering")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(AppColors.accent)
|
||||
.clipShape(Capsule())
|
||||
.shadow(color: AppColors.accent.opacity(0.35), radius: 8, y: 4)
|
||||
}
|
||||
.buttonStyle(PressButtonStyle(scale: 0.96))
|
||||
.padding(.top, 12)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $vm.showAddExercise) {
|
||||
if let sid = vm.addExerciseTargetSectionId {
|
||||
ExercisePickerSheet(sectionId: sid)
|
||||
@@ -169,6 +261,46 @@ struct WorkoutView: View {
|
||||
.presentationDetents([.height(240)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if vm.restCountdown > 0 {
|
||||
RestTimerBanner(
|
||||
countdown: vm.restCountdown,
|
||||
total: vm.restTimerTotal,
|
||||
onSkip: { vm.stopRestTimer() }
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 70)
|
||||
}
|
||||
}
|
||||
.onReceive(restTick) { _ in
|
||||
guard vm.restCountdown > 0 else { return }
|
||||
vm.restCountdown -= 1
|
||||
if vm.restCountdown == 0 {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
AudioServicesPlaySystemSound(1007)
|
||||
}
|
||||
}
|
||||
.animation(KisaniSpring.snappy, value: vm.restCountdown > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Program Icon
|
||||
// Renders SF Symbol names (contain ".") or legacy emoji strings.
|
||||
struct ProgramIcon: View {
|
||||
let name: String
|
||||
var size: CGFloat = 18
|
||||
var color: Color
|
||||
|
||||
private var isSFSymbol: Bool { name.contains(".") }
|
||||
|
||||
var body: some View {
|
||||
if isSFSymbol {
|
||||
Image(systemName: name)
|
||||
.font(.system(size: size, weight: .medium))
|
||||
.foregroundColor(color)
|
||||
} else {
|
||||
Text(name).font(.system(size: size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,10 +919,11 @@ private struct ProgramRow: View {
|
||||
// Tappable body → program detail
|
||||
Button { showDetail = true } label: {
|
||||
HStack(spacing: 12) {
|
||||
Text(program.emoji).font(.system(size: 22))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10).fill(AppColors.accentSoft)
|
||||
ProgramIcon(name: program.emoji, size: 20, color: AppColors.accent)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(program.name).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||||
@@ -865,7 +998,7 @@ struct ProgramDetailSheet: View {
|
||||
// Header
|
||||
HStack(spacing: 10) {
|
||||
if let p = program {
|
||||
Text(p.emoji).font(.system(size: 20))
|
||||
ProgramIcon(name: p.emoji, size: 18, color: AppColors.accent)
|
||||
Text(p.name).font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
}
|
||||
Spacer()
|
||||
@@ -1099,9 +1232,11 @@ private struct DayScheduleCell: View {
|
||||
.fill(assignedProgram != nil ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(isToday ? AppColors.accent : AppColors.border(cs), lineWidth: isToday ? 1.5 : 1))
|
||||
if let prog = assignedProgram {
|
||||
Text(prog.emoji).font(.system(size: 16))
|
||||
ProgramIcon(name: prog.emoji, size: 17, color: AppColors.accent)
|
||||
} else {
|
||||
Text("─").font(.system(size: 14)).foregroundColor(AppColors.text3(cs))
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
.frame(height: 44)
|
||||
@@ -1114,7 +1249,7 @@ private struct DayScheduleCell: View {
|
||||
.buttonStyle(.plain)
|
||||
.confirmationDialog("Assign \(day)", isPresented: $showPicker, titleVisibility: .visible) {
|
||||
ForEach(vm.programs) { prog in
|
||||
Button("\(prog.emoji) \(prog.name)") { vm.setSchedule(weekday: weekdayNum, programId: prog.id) }
|
||||
Button(prog.name) { vm.setSchedule(weekday: weekdayNum, programId: prog.id) }
|
||||
}
|
||||
Button("Rest Day", role: .destructive) { vm.setSchedule(weekday: weekdayNum, programId: nil) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
@@ -1131,7 +1266,16 @@ struct RenameProgramSheet: View {
|
||||
@State private var name: String
|
||||
@State private var selectedEmoji: String
|
||||
@FocusState private var nameFocused: Bool
|
||||
private let emojis = ["💪", "🦵", "🔄", "🏃", "🤸", "🏋️", "🧘", "🚴", "🥊", "🎯", "⚡️", "🫀"]
|
||||
private let iconSymbols = [
|
||||
"figure.strengthtraining.traditional", "figure.strengthtraining.functional",
|
||||
"figure.run", "figure.highintensity.intervaltraining",
|
||||
"figure.yoga", "figure.cycling",
|
||||
"figure.boxing", "figure.core.training",
|
||||
"figure.cooldown", "figure.arms.open",
|
||||
"dumbbell.fill", "heart.fill",
|
||||
"bolt.fill", "figure.walk",
|
||||
"figure.jumprope", "trophy.fill"
|
||||
]
|
||||
|
||||
init(program: WorkoutProgram) {
|
||||
self.program = program
|
||||
@@ -1162,14 +1306,21 @@ struct RenameProgramSheet: View {
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) {
|
||||
ForEach(emojis, id: \.self) { em in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedEmoji = em } } label: {
|
||||
Text(em).font(.system(size: 22))
|
||||
.frame(maxWidth: .infinity).frame(height: 44)
|
||||
.background(selectedEmoji == em ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(selectedEmoji == em ? AppColors.accent : AppColors.border(cs), lineWidth: selectedEmoji == em ? 1.5 : 1))
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
|
||||
ForEach(iconSymbols, id: \.self) { sym in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedEmoji = sym } } label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(selectedEmoji == sym ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(
|
||||
selectedEmoji == sym ? AppColors.accent : AppColors.border(cs),
|
||||
lineWidth: selectedEmoji == sym ? 1.5 : 1
|
||||
))
|
||||
Image(systemName: sym)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(selectedEmoji == sym ? AppColors.accent : AppColors.text2(cs))
|
||||
}
|
||||
.frame(height: 52)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -1203,9 +1354,18 @@ struct AddProgramSheet: View {
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var name = ""
|
||||
@State private var selectedEmoji = "💪"
|
||||
@State private var selectedEmoji = "figure.strengthtraining.traditional"
|
||||
@FocusState private var nameFocused: Bool
|
||||
private let emojis = ["💪", "🦵", "🔄", "🏃", "🤸", "🏋️", "🧘", "🚴", "🥊", "🎯", "⚡️", "🫀"]
|
||||
private let iconSymbols = [
|
||||
"figure.strengthtraining.traditional", "figure.strengthtraining.functional",
|
||||
"figure.run", "figure.highintensity.intervaltraining",
|
||||
"figure.yoga", "figure.cycling",
|
||||
"figure.boxing", "figure.core.training",
|
||||
"figure.cooldown", "figure.arms.open",
|
||||
"dumbbell.fill", "heart.fill",
|
||||
"bolt.fill", "figure.walk",
|
||||
"figure.jumprope", "trophy.fill"
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -1230,14 +1390,21 @@ struct AddProgramSheet: View {
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) {
|
||||
ForEach(emojis, id: \.self) { em in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedEmoji = em } } label: {
|
||||
Text(em).font(.system(size: 22))
|
||||
.frame(maxWidth: .infinity).frame(height: 44)
|
||||
.background(selectedEmoji == em ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(selectedEmoji == em ? AppColors.accent : AppColors.border(cs), lineWidth: selectedEmoji == em ? 1.5 : 1))
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
|
||||
ForEach(iconSymbols, id: \.self) { sym in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedEmoji = sym } } label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(selectedEmoji == sym ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(
|
||||
selectedEmoji == sym ? AppColors.accent : AppColors.border(cs),
|
||||
lineWidth: selectedEmoji == sym ? 1.5 : 1
|
||||
))
|
||||
Image(systemName: sym)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(selectedEmoji == sym ? AppColors.accent : AppColors.text2(cs))
|
||||
}
|
||||
.frame(height: 52)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user