698 lines
32 KiB
Swift
698 lines
32 KiB
Swift
import SwiftUI
|
|
import Bedrock
|
|
|
|
/// Sheet for creating or editing a ritual
|
|
struct RitualEditSheet: View {
|
|
@Bindable var store: RitualStore
|
|
@Bindable var categoryStore: CategoryStore
|
|
let ritual: Ritual?
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
// Form state
|
|
@State private var title: String = ""
|
|
@State private var theme: String = ""
|
|
@State private var notes: String = ""
|
|
@State private var durationDays: Double = 28
|
|
@State private var timeOfDay: TimeOfDay = .anytime
|
|
@State private var iconName: String = "sparkles"
|
|
@State private var selectedCategory: String = ""
|
|
@State private var habits: [EditableHabit] = []
|
|
|
|
@State private var showingIconPicker = false
|
|
@State private var showingHabitIconPicker = false
|
|
@State private var editingHabitIndex: Int?
|
|
@State private var newHabitTitle: String = ""
|
|
@State private var newHabitIcon: String = "circle.fill"
|
|
|
|
private var isEditing: Bool { ritual != nil }
|
|
|
|
private var canSave: Bool {
|
|
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
init(store: RitualStore, categoryStore: CategoryStore, ritual: Ritual?) {
|
|
self.store = store
|
|
self.categoryStore = categoryStore
|
|
self.ritual = ritual
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// Basic info section
|
|
basicInfoSection
|
|
|
|
// Schedule section
|
|
scheduleSection
|
|
|
|
// Habits section
|
|
habitsSection
|
|
|
|
// Notes section
|
|
notesSection
|
|
}
|
|
.environment(\.editMode, .constant(.active))
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppSurface.primary)
|
|
.navigationTitle(isEditing ? String(localized: "Edit Ritual") : String(localized: "New Ritual"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: "Cancel")) {
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "Save")) {
|
|
saveRitual()
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(AppAccent.primary)
|
|
.disabled(!canSave)
|
|
}
|
|
}
|
|
.onAppear {
|
|
loadExistingData()
|
|
}
|
|
.sheet(isPresented: $showingIconPicker) {
|
|
IconPickerSheet(selectedIcon: $iconName)
|
|
}
|
|
.sheet(isPresented: $showingHabitIconPicker) {
|
|
HabitIconPickerSheet(
|
|
selectedIcon: editingHabitIndex != nil
|
|
? Binding(
|
|
get: { habits[editingHabitIndex!].symbolName },
|
|
set: { habits[editingHabitIndex!].symbolName = $0 }
|
|
)
|
|
: $newHabitIcon
|
|
)
|
|
}
|
|
}
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
|
|
// MARK: - Form Sections
|
|
|
|
private var basicInfoSection: some View {
|
|
Section {
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
Button {
|
|
showingIconPicker = true
|
|
} label: {
|
|
Image(systemName: iconName)
|
|
.font(.title)
|
|
.foregroundStyle(AppAccent.primary)
|
|
.frame(width: 44, height: 44)
|
|
.background(AppSurface.card)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
|
TextField(String(localized: "Ritual name"), text: $title)
|
|
.font(.headline)
|
|
|
|
TextField(String(localized: "Theme or tagline"), text: $theme)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
}
|
|
.listRowBackground(AppSurface.card)
|
|
|
|
// Category selection - simple picker from CategoryStore
|
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
Text(String(localized: "Category"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
|
|
// Horizontal scrollable category chips
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: Design.Spacing.small) {
|
|
// None option
|
|
categoryChip(
|
|
label: String(localized: "None"),
|
|
color: nil,
|
|
isSelected: selectedCategory.isEmpty
|
|
) {
|
|
selectedCategory = ""
|
|
}
|
|
|
|
// All categories from store
|
|
ForEach(categoryStore.categories) { cat in
|
|
categoryChip(
|
|
label: cat.name,
|
|
color: cat.color,
|
|
isSelected: selectedCategory == cat.name
|
|
) {
|
|
selectedCategory = cat.name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(AppSurface.card)
|
|
} header: {
|
|
Text(String(localized: "Details"))
|
|
}
|
|
}
|
|
|
|
@State private var isEditingDuration: Bool = false
|
|
@State private var customDurationText: String = ""
|
|
|
|
private var scheduleSection: some View {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Picker(String(localized: "Time of Day"), selection: $timeOfDay) {
|
|
ForEach(TimeOfDay.allCases, id: \.self) { time in
|
|
Label(time.displayName, systemImage: time.symbolName)
|
|
.tag(time)
|
|
}
|
|
}
|
|
|
|
// Show the time range for the selected time of day
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
Image(systemName: timeOfDay.symbolName)
|
|
.font(.caption)
|
|
Text(timeOfDay.timeRange)
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
}
|
|
.listRowBackground(AppSurface.card)
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
HStack {
|
|
Text(String(localized: "Duration"))
|
|
Spacer()
|
|
|
|
if isEditingDuration {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
TextField("", text: $customDurationText)
|
|
.keyboardType(.numberPad)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 60)
|
|
.textFieldStyle(.roundedBorder)
|
|
.onSubmit {
|
|
applyCustomDuration()
|
|
}
|
|
|
|
Text(String(localized: "days"))
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
|
|
Button {
|
|
applyCustomDuration()
|
|
} label: {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(AppAccent.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
} else {
|
|
Button {
|
|
customDurationText = "\(Int(durationDays))"
|
|
isEditingDuration = true
|
|
} label: {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
Text(String(localized: "\(Int(durationDays)) days"))
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
Image(systemName: "pencil.circle")
|
|
.font(.caption)
|
|
.foregroundStyle(AppAccent.primary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
if !isEditingDuration {
|
|
Slider(value: $durationDays, in: 7...365, step: 1)
|
|
.tint(AppAccent.primary)
|
|
|
|
// Quick duration presets
|
|
HStack(spacing: Design.Spacing.small) {
|
|
ForEach([14, 21, 28, 30, 90], id: \.self) { days in
|
|
Button {
|
|
durationDays = Double(days)
|
|
} label: {
|
|
Text("\(days)")
|
|
.font(.caption)
|
|
.foregroundStyle(Int(durationDays) == days ? AppAccent.primary : AppTextColors.tertiary)
|
|
.padding(.horizontal, Design.Spacing.small)
|
|
.padding(.vertical, Design.Spacing.xSmall)
|
|
.background(Int(durationDays) == days ? AppAccent.primary.opacity(0.2) : AppSurface.secondary)
|
|
.clipShape(.capsule)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(AppSurface.card)
|
|
} header: {
|
|
Text(String(localized: "Schedule"))
|
|
} footer: {
|
|
Text(String(localized: "Tap the duration to enter a custom number of days (up to 365)."))
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
private func applyCustomDuration() {
|
|
if let value = Int(customDurationText), value >= 1, value <= 365 {
|
|
durationDays = Double(value)
|
|
}
|
|
isEditingDuration = false
|
|
}
|
|
|
|
private var habitsSection: some View {
|
|
Section {
|
|
ForEach(Array(habits.enumerated()), id: \.element.id) { index, habit in
|
|
HStack(spacing: Design.Spacing.small) {
|
|
// Drag handle
|
|
Image(systemName: "line.3.horizontal")
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
.frame(width: 20)
|
|
|
|
// Icon picker button
|
|
Button {
|
|
editingHabitIndex = index
|
|
showingHabitIconPicker = true
|
|
} label: {
|
|
Image(systemName: habit.symbolName)
|
|
.foregroundStyle(AppAccent.primary)
|
|
.frame(width: 24)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
TextField(String(localized: "Habit name"), text: $habits[index].title)
|
|
|
|
Button {
|
|
habits.removeAll { $0.id == habit.id }
|
|
} label: {
|
|
Image(systemName: "minus.circle.fill")
|
|
.foregroundStyle(AppStatus.error)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.listRowBackground(AppSurface.card)
|
|
}
|
|
.onMove { from, to in
|
|
habits.move(fromOffsets: from, toOffset: to)
|
|
}
|
|
|
|
// Add new habit row
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
Button {
|
|
editingHabitIndex = nil
|
|
showingHabitIconPicker = true
|
|
} label: {
|
|
Image(systemName: newHabitIcon)
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
.frame(width: 24)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
TextField(String(localized: "Add a habit..."), text: $newHabitTitle)
|
|
.onSubmit {
|
|
addNewHabit()
|
|
}
|
|
|
|
Button {
|
|
addNewHabit()
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
.foregroundStyle(AppAccent.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(newHabitTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
.listRowBackground(AppSurface.card)
|
|
} header: {
|
|
HStack {
|
|
Text(String(localized: "Habits"))
|
|
Spacer()
|
|
Text(String(localized: "\(habits.count) habits"))
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
}
|
|
} footer: {
|
|
if habits.count > 1 {
|
|
Text(String(localized: "Drag the handle to reorder habits."))
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var notesSection: some View {
|
|
Section {
|
|
TextField(String(localized: "Add notes or reminders..."), text: $notes, axis: .vertical)
|
|
.lineLimit(3...6)
|
|
.listRowBackground(AppSurface.card)
|
|
} header: {
|
|
Text(String(localized: "Notes"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func loadExistingData() {
|
|
guard let ritual = ritual else { return }
|
|
|
|
title = ritual.title
|
|
theme = ritual.theme
|
|
notes = ritual.notes
|
|
durationDays = Double(ritual.durationDays)
|
|
timeOfDay = ritual.timeOfDay
|
|
iconName = ritual.iconName
|
|
habits = ritual.habits.map { EditableHabit(from: $0) }
|
|
|
|
// Load category
|
|
selectedCategory = ritual.category
|
|
}
|
|
|
|
private func addNewHabit() {
|
|
let trimmedTitle = newHabitTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedTitle.isEmpty else { return }
|
|
|
|
habits.append(EditableHabit(title: trimmedTitle, symbolName: newHabitIcon))
|
|
newHabitTitle = ""
|
|
|
|
// Rotate to next icon
|
|
let icons = ["circle.fill", "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "flame.fill"]
|
|
if let currentIndex = icons.firstIndex(of: newHabitIcon) {
|
|
newHabitIcon = icons[(currentIndex + 1) % icons.count]
|
|
}
|
|
}
|
|
|
|
private func categoryChip(
|
|
label: String,
|
|
color: Color?,
|
|
isSelected: Bool,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
if let color {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 10, height: 10)
|
|
}
|
|
Text(label)
|
|
.font(.subheadline)
|
|
}
|
|
.padding(.horizontal, Design.Spacing.medium)
|
|
.padding(.vertical, Design.Spacing.small)
|
|
.background(isSelected ? (color ?? AppAccent.primary) : AppSurface.secondary)
|
|
.foregroundStyle(isSelected ? .white : AppTextColors.primary)
|
|
.clipShape(.capsule)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
|
|
private func saveRitual() {
|
|
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmedTheme = theme.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if let existingRitual = ritual {
|
|
// Update existing ritual
|
|
store.updateRitual(
|
|
existingRitual,
|
|
title: trimmedTitle,
|
|
theme: trimmedTheme,
|
|
notes: trimmedNotes,
|
|
durationDays: Int(durationDays),
|
|
timeOfDay: timeOfDay,
|
|
iconName: iconName,
|
|
category: selectedCategory
|
|
)
|
|
|
|
// Update habits - remove old, add new
|
|
// Note: This is a simplified approach; a more sophisticated solution would diff the habits
|
|
for habit in existingRitual.habits {
|
|
store.removeHabit(habit, from: existingRitual)
|
|
}
|
|
for editableHabit in habits {
|
|
store.addHabit(to: existingRitual, title: editableHabit.title, symbolName: editableHabit.symbolName)
|
|
}
|
|
} else {
|
|
// Create new ritual
|
|
let newHabits = habits.enumerated().map { index, habit in
|
|
ArcHabit(title: habit.title, symbolName: habit.symbolName, sortIndex: index)
|
|
}
|
|
store.createRitual(
|
|
title: trimmedTitle,
|
|
theme: trimmedTheme,
|
|
notes: trimmedNotes,
|
|
durationDays: Int(durationDays),
|
|
timeOfDay: timeOfDay,
|
|
iconName: iconName,
|
|
category: selectedCategory,
|
|
habits: newHabits
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A mutable habit for the edit form
|
|
private struct EditableHabit: Identifiable {
|
|
let id = UUID()
|
|
var title: String
|
|
var symbolName: String
|
|
|
|
init(title: String, symbolName: String) {
|
|
self.title = title
|
|
self.symbolName = symbolName
|
|
}
|
|
|
|
init(from habit: ArcHabit) {
|
|
self.title = habit.title
|
|
self.symbolName = habit.symbolName
|
|
}
|
|
}
|
|
|
|
/// Icon picker sheet for ritual icons
|
|
struct IconPickerSheet: View {
|
|
@Binding var selectedIcon: String
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var searchText: String = ""
|
|
|
|
private let iconGroups: [(name: String, icons: [String])] = [
|
|
("Wellness", ["heart.fill", "heart.circle.fill", "drop.fill", "leaf.fill", "flame.fill", "bolt.fill", "pill.fill", "cross.fill", "stethoscope.circle.fill", "bandage.fill", "lungs.fill", "ear.fill"]),
|
|
("Time", ["sunrise.fill", "sun.max.fill", "sunset.fill", "moon.stars.fill", "moon.fill", "moon.zzz.fill", "clock.fill", "hourglass", "timer", "alarm.fill", "calendar", "calendar.badge.clock"]),
|
|
("Activity", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "figure.strengthtraining.traditional", "figure.dance", "figure.pool.swim", "dumbbell.fill", "sportscourt.fill", "bicycle"]),
|
|
("Mind", ["brain", "brain.head.profile", "sparkles", "star.fill", "lightbulb.fill", "eye.fill", "wand.and.stars", "hands.and.sparkles.fill", "person.and.background.dotted"]),
|
|
("Objects", ["book.fill", "book.closed.fill", "books.vertical.fill", "pencil.and.list.clipboard", "cup.and.saucer.fill", "mug.fill", "bed.double.fill", "tshirt.fill", "fork.knife", "gift.fill", "key.fill"]),
|
|
("Nature", ["tree.fill", "wind", "cloud.fill", "snowflake", "sun.horizon.fill", "moon.fill", "mountain.2.fill", "water.waves", "leaf.arrow.circlepath", "flower.fill"]),
|
|
("Home", ["house.fill", "bed.double.fill", "bathtub.fill", "shower.fill", "lamp.desk.fill", "chair.fill", "sofa.fill", "washer.fill"]),
|
|
("Work", ["briefcase.fill", "folder.fill", "doc.text.fill", "list.bullet.clipboard.fill", "checklist", "tray.full.fill", "chart.bar.fill", "gauge.with.dots.needle.bottom.50percent"]),
|
|
("Social", ["person.fill", "person.2.fill", "person.wave.2.fill", "bubble.left.fill", "phone.fill", "envelope.fill", "hand.wave.fill", "face.smiling.fill"]),
|
|
("Actions", ["checkmark.circle.fill", "target", "scope", "hand.thumbsup.fill", "hand.raised.fill", "bell.fill", "megaphone.fill", "flag.fill", "pin.fill"]),
|
|
("Finance", ["banknote.fill", "dollarsign.circle.fill", "creditcard.fill", "cart.fill", "bag.fill"]),
|
|
("Travel", ["airplane", "car.fill", "tram.fill", "ferry.fill", "mappin.circle.fill", "globe.americas.fill"]),
|
|
("Art", ["paintpalette.fill", "paintbrush.fill", "pencil.tip.crop.circle", "scissors", "theatermasks.fill", "music.note", "guitars.fill"]),
|
|
("Tech", ["iphone", "desktopcomputer", "keyboard.fill", "headphones", "wifi", "antenna.radiowaves.left.and.right", "camera.fill", "photo.fill"]),
|
|
("Arrows", ["arrow.up.circle.fill", "arrow.down.circle.fill", "arrow.counterclockwise.circle.fill", "arrow.2.circlepath", "arrow.triangle.2.circlepath"])
|
|
]
|
|
|
|
private var allIcons: [String] {
|
|
iconGroups.flatMap { $0.icons }
|
|
}
|
|
|
|
private var filteredIcons: [String] {
|
|
if searchText.isEmpty {
|
|
return allIcons
|
|
}
|
|
return allIcons.filter { $0.localizedStandardContains(searchText) }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
if searchText.isEmpty {
|
|
// Show grouped icons
|
|
LazyVStack(alignment: .leading, spacing: Design.Spacing.large) {
|
|
ForEach(iconGroups, id: \.name) { group in
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Text(group.name)
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.padding(.horizontal, Design.Spacing.small)
|
|
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
|
ForEach(group.icons, id: \.self) { icon in
|
|
iconButton(icon)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
} else {
|
|
// Show filtered results
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
|
ForEach(filteredIcons, id: \.self) { icon in
|
|
iconButton(icon)
|
|
}
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
}
|
|
}
|
|
.background(AppSurface.primary)
|
|
.searchable(text: $searchText, prompt: String(localized: "Search icons"))
|
|
.navigationTitle(String(localized: "Choose Icon"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "Done")) {
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(AppAccent.primary)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
|
|
private func iconButton(_ icon: String) -> some View {
|
|
Button {
|
|
selectedIcon = icon
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: icon)
|
|
.font(.title3)
|
|
.foregroundStyle(selectedIcon == icon ? AppAccent.primary : AppTextColors.secondary)
|
|
.frame(width: 44, height: 44)
|
|
.background(selectedIcon == icon ? AppAccent.primary.opacity(0.2) : AppSurface.card)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
/// Icon picker sheet for habit icons with extensive SF Symbols
|
|
struct HabitIconPickerSheet: View {
|
|
@Binding var selectedIcon: String
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var searchText: String = ""
|
|
|
|
private let iconGroups: [(name: String, icons: [String])] = [
|
|
("Common", ["circle.fill", "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "flame.fill", "drop.fill", "sparkles", "checkmark.circle.fill", "target"]),
|
|
("Wellness", ["heart.fill", "heart.circle.fill", "cross.fill", "pill.fill", "stethoscope.circle.fill", "bandage.fill", "medical.thermometer.fill", "lungs.fill", "waveform.path.ecg", "face.smiling.fill"]),
|
|
("Fitness", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "figure.strengthtraining.traditional", "figure.dance", "figure.pool.swim", "figure.outdoor.cycle", "dumbbell.fill", "sportscourt.fill", "bicycle", "tennis.racket", "basketball.fill", "soccerball"]),
|
|
("Food & Drink", ["drop.fill", "cup.and.saucer.fill", "mug.fill", "fork.knife", "carrot.fill", "fish.fill", "leaf.fill", "takeoutbag.and.cup.and.straw.fill", "birthday.cake.fill", "wineglass.fill"]),
|
|
("Mind", ["brain", "brain.head.profile", "lightbulb.fill", "eye.fill", "ear.fill", "sparkles", "wand.and.stars", "hands.and.sparkles.fill", "person.and.background.dotted", "moon.zzz.fill"]),
|
|
("Reading & Writing", ["book.fill", "book.closed.fill", "books.vertical.fill", "text.book.closed.fill", "pencil", "pencil.and.list.clipboard", "note.text", "doc.text.fill", "newspaper.fill", "bookmark.fill"]),
|
|
("Time", ["clock.fill", "alarm.fill", "timer", "hourglass", "sunrise.fill", "sunset.fill", "moon.fill", "moon.stars.fill", "calendar", "calendar.badge.clock"]),
|
|
("Home", ["bed.double.fill", "bathtub.fill", "shower.fill", "washer.fill", "house.fill", "lamp.desk.fill", "chair.fill", "sofa.fill", "lightbulb.fill", "fan.fill"]),
|
|
("Work", ["briefcase.fill", "folder.fill", "tray.full.fill", "archivebox.fill", "calendar", "calendar.badge.plus", "checkmark.square.fill", "list.bullet.clipboard.fill", "checklist", "chart.bar.fill", "gauge.with.dots.needle.bottom.50percent", "laptopcomputer"]),
|
|
("Social", ["person.fill", "person.2.fill", "person.wave.2.fill", "bubble.left.fill", "phone.fill", "envelope.fill", "hand.wave.fill", "message.fill", "video.fill", "hand.thumbsup.fill"]),
|
|
("Nature", ["sun.max.fill", "cloud.fill", "wind", "snowflake", "tree.fill", "flower.fill", "mountain.2.fill", "water.waves", "leaf.arrow.circlepath", "globe.americas.fill"]),
|
|
("Tech", ["iphone", "desktopcomputer", "keyboard.fill", "headphones", "wifi", "antenna.radiowaves.left.and.right", "laptopcomputer", "applewatch", "airpods", "gamecontroller.fill"]),
|
|
("Finance", ["banknote.fill", "dollarsign.circle.fill", "creditcard.fill", "cart.fill", "bag.fill", "chart.line.uptrend.xyaxis", "building.columns.fill"]),
|
|
("Travel", ["airplane", "car.fill", "tram.fill", "ferry.fill", "mappin.circle.fill", "globe.americas.fill", "suitcase.fill", "fuelpump.fill"]),
|
|
("Art & Music", ["paintpalette.fill", "paintbrush.fill", "pencil.tip.crop.circle", "scissors", "theatermasks.fill", "music.note", "guitars.fill", "pianokeys", "headphones", "film.fill"]),
|
|
("Self-Care", ["comb.fill", "eyebrow", "lips.fill", "hand.raised.fingers.spread.fill", "sparkles", "shower.fill", "bathtub.fill", "leaf.fill"]),
|
|
("Cleaning", ["trash.fill", "archivebox.fill", "tshirt.fill", "washer.fill", "sparkles", "bubble.left.and.bubble.right.fill"]),
|
|
("Status", ["checkmark.circle.fill", "xmark.circle.fill", "exclamationmark.circle.fill", "questionmark.circle.fill", "target", "scope", "flag.fill", "rosette"]),
|
|
("Shapes", ["circle.fill", "square.fill", "triangle.fill", "diamond.fill", "hexagon.fill", "octagon.fill", "seal.fill", "staroflife.fill"]),
|
|
("Arrows", ["arrow.up.circle.fill", "arrow.down.circle.fill", "arrow.left.circle.fill", "arrow.right.circle.fill", "arrow.counterclockwise.circle.fill", "arrow.2.circlepath", "arrow.triangle.2.circlepath"]),
|
|
("Misc", ["gift.fill", "ticket.fill", "tag.fill", "pin.fill", "mappin.circle.fill", "key.fill", "lock.fill", "bell.fill", "megaphone.fill", "music.note", "photo.fill", "camera.fill", "puzzlepiece.fill", "dice.fill"])
|
|
]
|
|
|
|
private var allIcons: [String] {
|
|
// Remove duplicates while maintaining order
|
|
var seen = Set<String>()
|
|
return iconGroups.flatMap { $0.icons }.filter { seen.insert($0).inserted }
|
|
}
|
|
|
|
private var filteredIcons: [String] {
|
|
if searchText.isEmpty {
|
|
return allIcons
|
|
}
|
|
return allIcons.filter { $0.localizedStandardContains(searchText) }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
if searchText.isEmpty {
|
|
// Show grouped icons
|
|
LazyVStack(alignment: .leading, spacing: Design.Spacing.large) {
|
|
ForEach(iconGroups, id: \.name) { group in
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Text(group.name)
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.padding(.horizontal, Design.Spacing.small)
|
|
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
|
ForEach(group.icons, id: \.self) { icon in
|
|
iconButton(icon)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
} else {
|
|
// Show filtered results
|
|
if filteredIcons.isEmpty {
|
|
ContentUnavailableView(
|
|
String(localized: "No icons found"),
|
|
systemImage: "magnifyingglass",
|
|
description: Text(String(localized: "Try a different search term"))
|
|
)
|
|
} else {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
|
ForEach(filteredIcons, id: \.self) { icon in
|
|
iconButton(icon)
|
|
}
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
}
|
|
}
|
|
}
|
|
.background(AppSurface.primary)
|
|
.searchable(text: $searchText, prompt: String(localized: "Search icons (e.g., heart, star, book)"))
|
|
.navigationTitle(String(localized: "Choose Habit Icon"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "Done")) {
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(AppAccent.primary)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
|
|
private func iconButton(_ icon: String) -> some View {
|
|
Button {
|
|
selectedIcon = icon
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: icon)
|
|
.font(.title3)
|
|
.foregroundStyle(selectedIcon == icon ? AppAccent.primary : AppTextColors.secondary)
|
|
.frame(width: 44, height: 44)
|
|
.background(selectedIcon == icon ? AppAccent.primary.opacity(0.2) : AppSurface.card)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
RitualEditSheet(store: RitualStore.preview, categoryStore: CategoryStore.preview, ritual: nil)
|
|
}
|