Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-26 13:26:11 -06:00
parent 0223692513
commit 99d1db84e9
15 changed files with 676 additions and 852 deletions

View File

@ -8,6 +8,7 @@ struct AndromidaApp: App {
private let modelContainer: ModelContainer
@State private var store: RitualStore
@State private var settingsStore: SettingsStore
@State private var categoryStore: CategoryStore
init() {
// Include all models in schema - Ritual, RitualArc, and ArcHabit
@ -22,6 +23,7 @@ struct AndromidaApp: App {
modelContainer = container
let settings = SettingsStore()
_settingsStore = State(initialValue: settings)
_categoryStore = State(initialValue: CategoryStore())
_store = State(initialValue: RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings))
}
@ -33,7 +35,7 @@ struct AndromidaApp: App {
.ignoresSafeArea()
AppLaunchView(config: .rituals) {
RootView(store: store, settingsStore: settingsStore)
RootView(store: store, settingsStore: settingsStore, categoryStore: categoryStore)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
import Foundation
import SwiftUI
/// Represents a category for organizing rituals.
/// Categories can be global presets (non-deletable) or user-created.
struct Category: Identifiable, Codable, Equatable, Hashable {
let id: UUID
var name: String
var colorName: String
let isPreset: Bool
/// Available colors for categories
static let availableColors: [(name: String, color: Color)] = [
("red", .red),
("orange", .orange),
("yellow", .yellow),
("green", .green),
("mint", .mint),
("teal", .teal),
("cyan", .cyan),
("blue", .blue),
("indigo", .indigo),
("purple", .purple),
("pink", .pink),
("brown", .brown),
("gray", .gray)
]
/// The SwiftUI Color for this category
var color: Color {
Self.color(for: colorName)
}
/// Convert color name to SwiftUI Color
static func color(for name: String) -> Color {
availableColors.first { $0.name == name }?.color ?? .gray
}
/// Convert SwiftUI Color to storable name
static func colorName(for color: Color) -> String {
availableColors.first { $0.color == color }?.name ?? "gray"
}
// MARK: - Presets
/// Fixed UUIDs for presets to ensure consistency across app launches
private static let healthID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
private static let productivityID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!
private static let mindfulnessID = UUID(uuidString: "00000000-0000-0000-0000-000000000003")!
private static let selfCareID = UUID(uuidString: "00000000-0000-0000-0000-000000000004")!
/// Default preset categories
static let defaultPresets: [Category] = [
Category(id: healthID, name: "Health", colorName: "red", isPreset: true),
Category(id: productivityID, name: "Productivity", colorName: "blue", isPreset: true),
Category(id: mindfulnessID, name: "Mindfulness", colorName: "purple", isPreset: true),
Category(id: selfCareID, name: "Self-Care", colorName: "pink", isPreset: true)
]
/// Create a new user category
static func create(name: String, colorName: String) -> Category {
Category(id: UUID(), name: name, colorName: colorName, isPreset: false)
}
}

View File

@ -1,4 +1,5 @@
import Foundation
import SwiftUI
/// A template for a habit within a preset ritual
struct HabitPreset: Identifiable {
@ -20,7 +21,7 @@ struct RitualPreset: Identifiable {
let habits: [HabitPreset]
}
/// Categories for organizing presets
/// Categories for organizing presets in the preset library
enum PresetCategory: String, CaseIterable {
case health = "Health"
case productivity = "Productivity"

View File

@ -0,0 +1,150 @@
import Foundation
import Observation
import SwiftUI
/// Manages categories for rituals, including global presets and user-created categories.
@MainActor
@Observable
final class CategoryStore {
private let userDefaultsKey = "userCategories"
private let presetColorsKey = "presetCategoryColors"
/// All categories (presets + user-created), sorted alphabetically with presets first
private(set) var categories: [Category] = []
init() {
loadCategories()
}
// MARK: - Public API
/// Get a category by name
func category(named name: String) -> Category? {
categories.first { $0.name == name }
}
/// Get color for a category name, with fallback
func color(for categoryName: String) -> Color {
category(named: categoryName)?.color ?? .gray
}
/// Add a new user category
func addCategory(name: String, colorName: String) {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Don't add if name already exists
guard category(named: trimmedName) == nil else { return }
let newCategory = Category.create(name: trimmedName, colorName: colorName)
categories.append(newCategory)
sortCategories()
saveUserCategories()
}
/// Update a category's properties
func updateCategory(_ category: Category, name: String? = nil, colorName: String? = nil) {
guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return }
if category.isPreset {
// Presets can only change color
if let colorName {
categories[index].colorName = colorName
savePresetColors()
}
} else {
// User categories can change name and color
if let name {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
// Ensure no duplicate names
if !trimmedName.isEmpty && self.category(named: trimmedName) == nil {
categories[index].name = trimmedName
}
}
if let colorName {
categories[index].colorName = colorName
}
sortCategories()
saveUserCategories()
}
}
/// Delete a user category (presets cannot be deleted)
func deleteCategory(_ category: Category) {
guard !category.isPreset else { return }
categories.removeAll { $0.id == category.id }
saveUserCategories()
}
/// Check if a category name is available
func isNameAvailable(_ name: String, excluding: Category? = nil) -> Bool {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
return categories.first {
$0.name.lowercased() == trimmedName.lowercased() && $0.id != excluding?.id
} == nil
}
// MARK: - Persistence
private func loadCategories() {
// Start with default presets
var loadedCategories = Category.defaultPresets
// Apply saved preset colors
if let presetColors = UserDefaults.standard.dictionary(forKey: presetColorsKey) as? [String: String] {
for i in loadedCategories.indices where loadedCategories[i].isPreset {
if let savedColor = presetColors[loadedCategories[i].name] {
loadedCategories[i].colorName = savedColor
}
}
}
// Load user categories
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let userCategories = try? JSONDecoder().decode([Category].self, from: data) {
loadedCategories.append(contentsOf: userCategories)
}
categories = loadedCategories
sortCategories()
}
private func saveUserCategories() {
let userCategories = categories.filter { !$0.isPreset }
if let data = try? JSONEncoder().encode(userCategories) {
UserDefaults.standard.set(data, forKey: userDefaultsKey)
}
}
private func savePresetColors() {
let presetColors = categories
.filter { $0.isPreset }
.reduce(into: [String: String]()) { dict, cat in
dict[cat.name] = cat.colorName
}
UserDefaults.standard.set(presetColors, forKey: presetColorsKey)
}
private func sortCategories() {
// Presets first (in their defined order), then user categories alphabetically
let presets = categories.filter { $0.isPreset }.sorted { cat1, cat2 in
let idx1 = Category.defaultPresets.firstIndex(where: { $0.id == cat1.id }) ?? 0
let idx2 = Category.defaultPresets.firstIndex(where: { $0.id == cat2.id }) ?? 0
return idx1 < idx2
}
let userCats = categories.filter { !$0.isPreset }.sorted { $0.name < $1.name }
categories = presets + userCats
}
}
// MARK: - Preview Support
extension CategoryStore {
static var preview: CategoryStore {
let store = CategoryStore()
store.addCategory(name: "Fitness", colorName: "green")
store.addCategory(name: "Learning", colorName: "orange")
return store
}
}

View File

@ -3,6 +3,7 @@ import Bedrock
struct RitualDetailView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
@Environment(\.dismiss) private var dismiss
private let ritual: Ritual
@ -12,8 +13,9 @@ struct RitualDetailView: View {
@State private var showingEndArcConfirmation = false
@State private var showingStartArcConfirmation = false
init(store: RitualStore, ritual: Ritual) {
init(store: RitualStore, categoryStore: CategoryStore, ritual: Ritual) {
self.store = store
self.categoryStore = categoryStore
self.ritual = ritual
}
@ -121,7 +123,7 @@ struct RitualDetailView: View {
}
}
.sheet(isPresented: $showingEditSheet) {
RitualEditSheet(store: store, ritual: ritual)
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
}
.alert(String(localized: "Delete Ritual?"), isPresented: $showingDeleteConfirmation) {
Button(String(localized: "Cancel"), role: .cancel) {}
@ -297,13 +299,14 @@ struct RitualDetailView: View {
// Category badge (if set)
if !ritual.category.isEmpty {
let badgeColor = categoryStore.color(for: ritual.category)
Text(ritual.category)
.font(.caption)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(AppSurface.card)
.background(badgeColor.opacity(0.15))
.clipShape(.capsule)
.foregroundStyle(AppTextColors.secondary)
.foregroundStyle(badgeColor)
}
Spacer()
@ -456,6 +459,6 @@ struct RitualDetailView: View {
#Preview {
NavigationStack {
RitualDetailView(store: RitualStore.preview, ritual: RitualStore.preview.rituals.first!)
RitualDetailView(store: RitualStore.preview, categoryStore: CategoryStore.preview, ritual: RitualStore.preview.rituals.first!)
}
}

View File

@ -3,6 +3,7 @@ import Bedrock
struct RitualsView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
@State private var selectedTab: RitualsTab = .current
@State private var showingPresetLibrary = false
@State private var showingCreateRitual = false
@ -76,7 +77,7 @@ struct RitualsView: View {
PresetLibrarySheet(store: store)
}
.sheet(isPresented: $showingCreateRitual) {
RitualEditSheet(store: store, ritual: nil)
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: nil)
}
.alert(String(localized: "Delete Ritual?"), isPresented: .init(
get: { ritualToDelete != nil },
@ -225,7 +226,7 @@ struct RitualsView: View {
private func currentRitualRow(for ritual: Ritual) -> some View {
NavigationLink {
RitualDetailView(store: store, ritual: ritual)
RitualDetailView(store: store, categoryStore: categoryStore, ritual: ritual)
} label: {
RitualCardView(
title: ritual.title,
@ -271,7 +272,7 @@ struct RitualsView: View {
private func pastRitualRow(for ritual: Ritual) -> some View {
NavigationLink {
RitualDetailView(store: store, ritual: ritual)
RitualDetailView(store: store, categoryStore: categoryStore, ritual: ritual)
} label: {
pastRitualCardView(for: ritual)
}
@ -370,6 +371,6 @@ struct RitualsView: View {
#Preview {
NavigationStack {
RitualsView(store: RitualStore.preview)
RitualsView(store: RitualStore.preview, categoryStore: CategoryStore.preview)
}
}

View File

@ -4,14 +4,16 @@ import Bedrock
/// Sheet presented when a ritual's arc completes, prompting the user to renew or end.
struct ArcRenewalSheet: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
let ritual: Ritual
@Environment(\.dismiss) private var dismiss
@State private var durationDays: Double
@State private var showingEditSheet = false
init(store: RitualStore, ritual: Ritual) {
init(store: RitualStore, categoryStore: CategoryStore, ritual: Ritual) {
self.store = store
self.categoryStore = categoryStore
self.ritual = ritual
_durationDays = State(initialValue: Double(ritual.defaultDurationDays))
}
@ -53,7 +55,7 @@ struct ArcRenewalSheet: View {
}
}
.sheet(isPresented: $showingEditSheet) {
RitualEditSheet(store: store, ritual: ritual)
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
}
}

View File

@ -4,6 +4,7 @@ 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
@ -15,9 +16,7 @@ struct RitualEditSheet: View {
@State private var durationDays: Double = 28
@State private var timeOfDay: TimeOfDay = .anytime
@State private var iconName: String = "sparkles"
@State private var category: String = ""
@State private var customCategory: String = ""
@State private var isUsingCustomCategory: Bool = false
@State private var selectedCategory: String = ""
@State private var habits: [EditableHabit] = []
@State private var showingIconPicker = false
@ -32,8 +31,9 @@ struct RitualEditSheet: View {
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
init(store: RitualStore, ritual: Ritual?) {
init(store: RitualStore, categoryStore: CategoryStore, ritual: Ritual?) {
self.store = store
self.categoryStore = categoryStore
self.ritual = ritual
}
@ -122,22 +122,35 @@ struct RitualEditSheet: View {
}
.listRowBackground(AppSurface.card)
// Category selection with custom option
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Picker(String(localized: "Category"), selection: $category) {
Text(String(localized: "None")).tag("")
ForEach(PresetCategory.allCases, id: \.self) { cat in
Text(cat.displayName).tag(cat.rawValue)
}
Text(String(localized: "Custom...")).tag("__custom__")
}
.onChange(of: category) { _, newValue in
isUsingCustomCategory = (newValue == "__custom__")
// 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 = ""
}
if isUsingCustomCategory {
TextField(String(localized: "Enter custom category"), text: $customCategory)
.textFieldStyle(.roundedBorder)
// 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)
@ -357,19 +370,8 @@ struct RitualEditSheet: View {
iconName = ritual.iconName
habits = ritual.habits.map { EditableHabit(from: $0) }
// Check if category is a preset or custom
let presetCategories = PresetCategory.allCases.map { $0.rawValue }
if ritual.category.isEmpty {
category = ""
isUsingCustomCategory = false
} else if presetCategories.contains(ritual.category) {
category = ritual.category
isUsingCustomCategory = false
} else {
category = "__custom__"
customCategory = ritual.category
isUsingCustomCategory = true
}
// Load category
selectedCategory = ritual.category
}
private func addNewHabit() {
@ -386,15 +388,31 @@ struct RitualEditSheet: View {
}
}
private var effectiveCategory: String {
if isUsingCustomCategory {
return customCategory.trimmingCharacters(in: .whitespacesAndNewlines)
} else if category == "__custom__" {
return ""
} else {
return category
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)
@ -411,7 +429,7 @@ struct RitualEditSheet: View {
durationDays: Int(durationDays),
timeOfDay: timeOfDay,
iconName: iconName,
category: effectiveCategory
category: selectedCategory
)
// Update habits - remove old, add new
@ -432,7 +450,7 @@ struct RitualEditSheet: View {
durationDays: Int(durationDays),
timeOfDay: timeOfDay,
iconName: iconName,
category: effectiveCategory,
category: selectedCategory,
habits: newHabits
)
}
@ -661,5 +679,5 @@ struct HabitIconPickerSheet: View {
}
#Preview {
RitualEditSheet(store: RitualStore.preview, ritual: nil)
RitualEditSheet(store: RitualStore.preview, categoryStore: CategoryStore.preview, ritual: nil)
}

View File

@ -5,6 +5,7 @@ import Sherpa
struct RootView: View {
@Bindable var store: RitualStore
@Bindable var settingsStore: SettingsStore
@Bindable var categoryStore: CategoryStore
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: RootTab = .today
@ -21,13 +22,13 @@ struct RootView: View {
TabView(selection: $selectedTab) {
Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) {
NavigationStack {
TodayView(store: store)
TodayView(store: store, categoryStore: categoryStore)
}
}
Tab(String(localized: "Rituals"), systemImage: "sparkles", value: RootTab.rituals) {
NavigationStack {
RitualsView(store: store)
RitualsView(store: store, categoryStore: categoryStore)
}
}
@ -45,7 +46,7 @@ struct RootView: View {
Tab(String(localized: "Settings"), systemImage: "gearshape.fill", value: RootTab.settings) {
NavigationStack {
SettingsView(store: settingsStore, ritualStore: store)
SettingsView(store: settingsStore, ritualStore: store, categoryStore: categoryStore)
}
}
}
@ -77,7 +78,7 @@ struct RootView: View {
}
#Preview {
RootView(store: RitualStore.preview, settingsStore: SettingsStore.preview)
RootView(store: RitualStore.preview, settingsStore: SettingsStore.preview, categoryStore: CategoryStore.preview)
}
extension RootView: SherpaDelegate {

View File

@ -0,0 +1,156 @@
import SwiftUI
import Bedrock
/// Sheet for creating or editing a category
struct CategoryEditSheet: View {
@Bindable var store: CategoryStore
let category: Category?
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var selectedColorName: String = "gray"
@State private var showingDeleteConfirmation = false
private var isEditing: Bool { category != nil }
private var isPreset: Bool { category?.isPreset ?? false }
private var canSave: Bool {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
if isPreset {
// Presets can always save (just color change)
return true
}
// New/user categories need a valid unique name
return !trimmedName.isEmpty && store.isNameAvailable(trimmedName, excluding: category)
}
var body: some View {
NavigationStack {
Form {
Section {
if isPreset {
// Preset name is read-only
HStack {
Text(String(localized: "Name"))
Spacer()
Text(name)
.foregroundStyle(AppTextColors.secondary)
}
} else {
TextField(String(localized: "Category Name"), text: $name)
}
} header: {
Text(String(localized: "Name"))
}
.listRowBackground(AppSurface.card)
Section {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 44))], spacing: Design.Spacing.medium) {
ForEach(Category.availableColors, id: \.name) { colorOption in
Button {
selectedColorName = colorOption.name
} label: {
Circle()
.fill(colorOption.color)
.frame(width: 40, height: 40)
.overlay(
Circle()
.strokeBorder(Color.white, lineWidth: selectedColorName == colorOption.name ? 3 : 0)
)
.shadow(color: selectedColorName == colorOption.name ? colorOption.color.opacity(0.5) : .clear, radius: 4)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Design.Spacing.small)
} header: {
Text(String(localized: "Color"))
}
.listRowBackground(AppSurface.card)
// Delete button for user categories
if isEditing && !isPreset {
Section {
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
HStack {
Spacer()
Text(String(localized: "Delete Category"))
Spacer()
}
}
}
.listRowBackground(AppSurface.card)
}
}
.scrollContentBackground(.hidden)
.background(AppSurface.primary)
.navigationTitle(isEditing ? String(localized: "Edit Category") : String(localized: "New Category"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "Save")) {
save()
dismiss()
}
.disabled(!canSave)
}
}
.onAppear {
loadCategory()
}
.alert(String(localized: "Delete Category?"), isPresented: $showingDeleteConfirmation) {
Button(String(localized: "Cancel"), role: .cancel) {}
Button(String(localized: "Delete"), role: .destructive) {
if let category {
store.deleteCategory(category)
}
dismiss()
}
} message: {
Text(String(localized: "Rituals using this category will be set to no category."))
}
}
.presentationDetents([.medium])
}
private func loadCategory() {
if let category {
name = category.name
selectedColorName = category.colorName
} else {
name = ""
selectedColorName = "blue" // Default for new categories
}
}
private func save() {
if let category {
// Update existing
if isPreset {
store.updateCategory(category, colorName: selectedColorName)
} else {
store.updateCategory(category, name: name, colorName: selectedColorName)
}
} else {
// Create new
store.addCategory(name: name, colorName: selectedColorName)
}
}
}
#Preview("New Category") {
CategoryEditSheet(store: CategoryStore.preview, category: nil)
}
#Preview("Edit Preset") {
CategoryEditSheet(store: CategoryStore.preview, category: Category.defaultPresets.first!)
}

View File

@ -0,0 +1,132 @@
import SwiftUI
import Bedrock
/// View for managing categories - presets and user-created
struct CategoryListView: View {
@Bindable var store: CategoryStore
@State private var showingAddSheet = false
@State private var categoryToEdit: Category?
@State private var categoryToDelete: Category?
private var presetCategories: [Category] {
store.categories.filter { $0.isPreset }
}
private var userCategories: [Category] {
store.categories.filter { !$0.isPreset }
}
var body: some View {
List {
// Preset categories section
Section {
ForEach(presetCategories) { category in
categoryRow(category)
.onTapGesture {
categoryToEdit = category
}
}
} header: {
Text(String(localized: "Default Categories"))
} footer: {
Text(String(localized: "Default categories cannot be renamed or deleted, but you can change their color."))
}
.listRowBackground(AppSurface.card)
// User categories section
Section {
if userCategories.isEmpty {
Text(String(localized: "No custom categories yet"))
.font(.subheadline)
.foregroundStyle(AppTextColors.tertiary)
} else {
ForEach(userCategories) { category in
categoryRow(category)
.onTapGesture {
categoryToEdit = category
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
categoryToDelete = category
} label: {
Label(String(localized: "Delete"), systemImage: "trash")
}
}
}
}
} header: {
Text(String(localized: "Custom Categories"))
}
.listRowBackground(AppSurface.card)
}
.scrollContentBackground(.hidden)
.background(AppSurface.primary)
.navigationTitle(String(localized: "Categories"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAddSheet = true
} label: {
Image(systemName: "plus")
.foregroundStyle(AppAccent.primary)
}
}
}
.sheet(isPresented: $showingAddSheet) {
CategoryEditSheet(store: store, category: nil)
}
.sheet(item: $categoryToEdit) { category in
CategoryEditSheet(store: store, category: category)
}
.alert(String(localized: "Delete Category?"), isPresented: .init(
get: { categoryToDelete != nil },
set: { if !$0 { categoryToDelete = nil } }
)) {
Button(String(localized: "Cancel"), role: .cancel) {
categoryToDelete = nil
}
Button(String(localized: "Delete"), role: .destructive) {
if let category = categoryToDelete {
store.deleteCategory(category)
}
categoryToDelete = nil
}
} message: {
Text(String(localized: "Rituals using this category will be set to no category."))
}
}
private func categoryRow(_ category: Category) -> some View {
HStack(spacing: Design.Spacing.medium) {
Circle()
.fill(category.color)
.frame(width: 24, height: 24)
Text(category.name)
.font(.body)
.foregroundStyle(AppTextColors.primary)
Spacer()
if category.isPreset {
Text(String(localized: "Default"))
.font(.caption)
.foregroundStyle(AppTextColors.tertiary)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(AppTextColors.tertiary)
}
.padding(.vertical, Design.Spacing.xSmall)
.contentShape(Rectangle())
}
}
#Preview {
NavigationStack {
CategoryListView(store: CategoryStore.preview)
}
}

View File

@ -4,6 +4,7 @@ import Bedrock
struct SettingsView: View {
@Bindable var store: SettingsStore
var ritualStore: RitualStore?
var categoryStore: CategoryStore?
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
@ -37,6 +38,24 @@ struct SettingsView: View {
)
}
SettingsSectionHeader(
title: String(localized: "Customization"),
systemImage: "paintbrush",
accentColor: AppAccent.primary
)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
if let categoryStore {
SettingsNavigationRow(
title: String(localized: "Categories"),
subtitle: String(localized: "Manage ritual categories"),
backgroundColor: AppSurface.primary
) {
CategoryListView(store: categoryStore)
}
}
}
SettingsSectionHeader(
title: String(localized: "iCloud Sync"),
systemImage: "icloud",

View File

@ -3,6 +3,7 @@ import Bedrock
struct TodayEmptyStateView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
@State private var showingPresetLibrary = false
@State private var showingCreateRitual = false
@ -70,13 +71,13 @@ struct TodayEmptyStateView: View {
PresetLibrarySheet(store: store)
}
.sheet(isPresented: $showingCreateRitual) {
RitualEditSheet(store: store, ritual: nil)
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: nil)
}
}
}
#Preview {
TodayEmptyStateView(store: RitualStore.preview)
TodayEmptyStateView(store: RitualStore.preview, categoryStore: CategoryStore.preview)
.padding()
.background(AppSurface.primary)
}

View File

@ -3,6 +3,7 @@ import Bedrock
struct TodayView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
/// Rituals to show now based on current time of day
private var todayRituals: [Ritual] {
@ -30,7 +31,7 @@ struct TodayView: View {
TodayNoRitualsForTimeView(store: store)
} else {
// No active rituals at all
TodayEmptyStateView(store: store)
TodayEmptyStateView(store: store, categoryStore: categoryStore)
}
} else {
ForEach(todayRituals) { ritual in
@ -62,7 +63,7 @@ struct TodayView: View {
set: { if !$0 { store.dismissRenewalPrompt() } }
)) {
if let ritual = store.ritualNeedingRenewal {
ArcRenewalSheet(store: store, ritual: ritual)
ArcRenewalSheet(store: store, categoryStore: categoryStore, ritual: ritual)
}
}
}
@ -81,5 +82,5 @@ struct TodayView: View {
}
#Preview {
TodayView(store: RitualStore.preview)
TodayView(store: RitualStore.preview, categoryStore: CategoryStore.preview)
}