Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
0223692513
commit
99d1db84e9
@ -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
64
Andromida/App/Models/Category.swift
Normal file
64
Andromida/App/Models/Category.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
150
Andromida/App/State/CategoryStore.swift
Normal file
150
Andromida/App/State/CategoryStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
156
Andromida/App/Views/Settings/CategoryEditSheet.swift
Normal file
156
Andromida/App/Views/Settings/CategoryEditSheet.swift
Normal 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!)
|
||||
}
|
||||
132
Andromida/App/Views/Settings/CategoryListView.swift
Normal file
132
Andromida/App/Views/Settings/CategoryListView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user