diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index 0cf5257..f33575b 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -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) } } } diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index 831ff6f..6ba92fb 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -284,56 +284,10 @@ "comment" : "A description of a milestone that has been completed.", "isCommentAutoGenerated" : true }, - "Across all rituals" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Across all rituals" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "En todos los rituales" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dans tous les rituels" - } - } - } - }, "Active" : { "comment" : "Title for an insight card showing the number of active rituals.", "isCommentAutoGenerated" : true }, - "Active rituals" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Active rituals" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rituales activos" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rituels actifs" - } - } - } - }, "Add a habit..." : { "comment" : "A placeholder text for a text field that allows users to input the name of a new habit.", "isCommentAutoGenerated" : true @@ -354,29 +308,6 @@ "comment" : "Tip to consider adding more habits to build momentum.", "isCommentAutoGenerated" : true }, - "Adjust arc duration" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adjust arc duration" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajusta la duración del arco" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajustez la durée de l’arc" - } - } - } - }, "After 9pm" : { "comment" : "Time range description for the \"Night\" time of day.", "isCommentAutoGenerated" : true @@ -430,29 +361,6 @@ "comment" : "Time range description for the \"Morning\" time of day.", "isCommentAutoGenerated" : true }, - "Begin a four-week arc" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Begin a four-week arc" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Comienza un arco de cuatro semanas" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Commencez un arc de quatre semaines" - } - } - } - }, "Below weekly average" : { "comment" : "Label text for a badge indicating that their habit completion rate is below the average for the week.", "isCommentAutoGenerated" : true @@ -519,60 +427,22 @@ "comment" : "Theme of the \"Morning Skincare\" ritual preset.", "isCommentAutoGenerated" : true }, + "Categories" : { + "comment" : "The title of the view that lists and manages categories.", + "isCommentAutoGenerated" : true + }, "Category" : { "comment" : "A label for the category of a ritual.", "isCommentAutoGenerated" : true }, + "Category Name" : { + "comment" : "A label for the name of a category.", + "isCommentAutoGenerated" : true + }, "Change into comfortable clothes" : { "comment" : "Habit title for a ritual preset that encourages dressing in comfortable clothes for the evening.", "isCommentAutoGenerated" : true }, - "Check-ins completed" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Check-ins completed" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Registros completados" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilans complétés" - } - } - } - }, - "Choose a theme and keep your focus clear for 28 days." : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose a theme and keep your focus clear for 28 days." - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige un tema y mantén tu enfoque claro durante 28 días." - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisis un thème et garde ton intention claire pendant 28 jours." - } - } - } - }, "Choose Habit Icon" : { "comment" : "The title of the icon picker sheet.", "isCommentAutoGenerated" : true @@ -585,29 +455,6 @@ "comment" : "Title of a habit within a \"Focus Reset\" ritual preset.", "isCommentAutoGenerated" : true }, - "Choose the intensity of your arc" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose the intensity of your arc" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige la intensidad de tu arco" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisissez l’intensité de votre arc" - } - } - } - }, "Cleanse face" : { "comment" : "Title of a habit within a Self-Care Ritual Preset.", "isCommentAutoGenerated" : true @@ -635,6 +482,10 @@ "comment" : "Title of a habit in a ritual preset focused on productivity, related to closing unnecessary tabs on a device.", "isCommentAutoGenerated" : true }, + "Color" : { + "comment" : "A label describing the color option for a custom ritual category.", + "isCommentAutoGenerated" : true + }, "Combat sedentary habits with midday activity." : { "comment" : "Notes for a ritual preset focused on breaking up a day with midday activity.", "isCommentAutoGenerated" : true @@ -737,29 +588,6 @@ "comment" : "A button label that suggests creating a new ritual.", "isCommentAutoGenerated" : true }, - "Create ritual" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Create ritual" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Crear ritual" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Créer un rituel" - } - } - } - }, "Create the conditions for uninterrupted deep work." : { "comment" : "Notes section of a productivity ritual preset.", "isCommentAutoGenerated" : true @@ -794,6 +622,10 @@ "comment" : "Label for a breakdown item in the \"Streak\" insight card, indicating the current streak of consecutive days with 100% habit completion.", "isCommentAutoGenerated" : true }, + "Custom Categories" : { + "comment" : "A section header in the category list view, indicating that the section contains user-created categories.", + "isCommentAutoGenerated" : true + }, "Custom Ritual" : { "localizations" : { "en" : { @@ -816,37 +648,14 @@ } } }, - "Custom..." : { - "comment" : "A text option in the ritual category picker that allows users to input their own custom category.", + "Customization" : { + "comment" : "Section header title in the Settings view for customization options.", "isCommentAutoGenerated" : true }, "Daily at %@" : { "comment" : "A subtitle for the \"Reminders\" section in the settings view, showing which times are set for reminders.", "isCommentAutoGenerated" : true }, - "Daily reminders" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Daily reminders" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recordatorios diarios" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rappels quotidiens" - } - } - } - }, "Day" : { "comment" : "Label for the x-axis in the mini sparkline chart within an `InsightCardView`.", "isCommentAutoGenerated" : true @@ -937,6 +746,17 @@ "comment" : "Title of a ritual preset focused on setting up for focused work.", "isCommentAutoGenerated" : true }, + "Default" : { + "comment" : "A label indicating that a category is a default one.", + "isCommentAutoGenerated" : true + }, + "Default Categories" : { + + }, + "Default categories cannot be renamed or deleted, but you can change their color." : { + "comment" : "A footer label under the \"Default Categories\" section of the category list view, explaining that default categories cannot be renamed or deleted but can have their colors changed.", + "isCommentAutoGenerated" : true + }, "Define your top 3 priorities" : { "comment" : "Title of a habit within a ritual preset focused on productivity.", "isCommentAutoGenerated" : true @@ -945,6 +765,14 @@ "comment" : "A destructive button that deletes a ritual.", "isCommentAutoGenerated" : true }, + "Delete Category" : { + "comment" : "A button label that deletes a category.", + "isCommentAutoGenerated" : true + }, + "Delete Category?" : { + "comment" : "An alert title that asks if the user wants to delete a category.", + "isCommentAutoGenerated" : true + }, "Delete Ritual?" : { "comment" : "The title of an alert that asks if the user wants to delete a ritual.", "isCommentAutoGenerated" : true @@ -995,29 +823,6 @@ "comment" : "A hint text that appears when a user long-presses a \"Today's Habits\" row, explaining that tapping it again will mark the task as incomplete.", "isCommentAutoGenerated" : true }, - "Double tap to toggle" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Double tap to toggle" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Doble toque para alternar" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Double-touchez pour basculer" - } - } - } - }, "Drag the handle to reorder habits." : { "comment" : "A footer text displayed below the list of habits in the ritual edit sheet, explaining how to reorder them by dragging.", "isCommentAutoGenerated" : true @@ -1068,6 +873,10 @@ "comment" : "A button label that translates to \"Edit\".", "isCommentAutoGenerated" : true }, + "Edit Category" : { + "comment" : "The title of the navigation bar in the category edit sheet when editing an existing category.", + "isCommentAutoGenerated" : true + }, "Edit Ritual" : { "comment" : "The title of the navigation bar for editing a ritual.", "isCommentAutoGenerated" : true @@ -1099,10 +908,6 @@ "comment" : "A motivational message displayed below the \"All caught up\" section in the \"Today\" view when there are no active rituals scheduled for the current time of day.", "isCommentAutoGenerated" : true }, - "Enter custom category" : { - "comment" : "A text field label for entering a custom category name.", - "isCommentAutoGenerated" : true - }, "Evening" : { "comment" : "Description of a ritual scheduling option when the ritual should be visible in the Today view after 5pm.", "isCommentAutoGenerated" : true @@ -1149,29 +954,6 @@ "comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.", "isCommentAutoGenerated" : true }, - "Feel a soft response on check-in" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Feel a soft response on check-in" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Siente una respuesta suave al registrar" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ressentez une réponse douce lors du suivi" - } - } - } - }, "Find the good" : { "comment" : "Title of a habit in a ritual preset focused on practicing gratitude.", "isCommentAutoGenerated" : true @@ -1188,75 +970,6 @@ "comment" : "Title of a ritual preset that encourages users to refocus when they feel scattered.", "isCommentAutoGenerated" : true }, - "Focus ritual" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Focus ritual" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ritual de enfoque" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rituel principal" - } - } - } - }, - "Focus style" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Focus style" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Estilo de enfoque" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Style de concentration" - } - } - } - }, - "Four-week arc in progress" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Four-week arc in progress" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arco de cuatro semanas en progreso" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arc de quatre semaines en cours" - } - } - } - }, "Four-week focus for daily habits" : { "localizations" : { "en" : { @@ -1323,56 +1036,10 @@ } } }, - "Gentle" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gentle" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Suave" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Doux" - } - } - } - }, "Gentle stretching" : { "comment" : "Habit title for a ritual preset focused on self-care, emphasizing gentle stretching as a habit.", "isCommentAutoGenerated" : true }, - "Get a gentle check-in each morning" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Get a gentle check-in each morning" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recibe un recordatorio suave cada mañana" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recevez un rappel doux chaque matin" - } - } - } - }, "Get reminded when it's time for your rituals" : { "comment" : "Default text to show in the reminder subtitle when the ritual store is unavailable.", "isCommentAutoGenerated" : true @@ -1579,29 +1246,6 @@ } } }, - "Intense" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Intense" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Intenso" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Intense" - } - } - } - }, "Journal for 5 minutes" : { "comment" : "Habit title for a mindfulness ritual where the user writes in a journal for 5 minutes.", "isCommentAutoGenerated" : true @@ -1622,29 +1266,6 @@ } } }, - "Last synced %@" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last synced %@" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Última sincronización %@" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dernière synchronisation %@" - } - } - } - }, "Later" : { "comment" : "A button that dismisses the renewal prompt and returns to the main screen.", "isCommentAutoGenerated" : true @@ -1661,6 +1282,10 @@ "comment" : "Label for the longest streak breakdown item.", "isCommentAutoGenerated" : true }, + "Manage ritual categories" : { + "comment" : "Title of a navigation row in the Settings view that takes the user to a view managing ritual categories.", + "isCommentAutoGenerated" : true + }, "Midday" : { "comment" : "Description of a ritual is typically performed during the day.", "isCommentAutoGenerated" : true @@ -1784,6 +1409,14 @@ } } }, + "Name" : { + "comment" : "A heading displayed above the text field for a category's name.", + "isCommentAutoGenerated" : true + }, + "New Category" : { + "comment" : "The title of the view, which changes based on whether a category is being edited or created.", + "isCommentAutoGenerated" : true + }, "New Ritual" : { "comment" : "The title of the view when creating a new ritual.", "isCommentAutoGenerated" : true @@ -1815,6 +1448,10 @@ "comment" : "A message displayed when a ritual has no completed arcs.", "isCommentAutoGenerated" : true }, + "No custom categories yet" : { + "comment" : "A message displayed when a user has not yet created any custom categories.", + "isCommentAutoGenerated" : true + }, "No habits tracked" : { "comment" : "A message displayed when a user has not tracked any habits on a given day.", "isCommentAutoGenerated" : true @@ -1827,29 +1464,6 @@ "comment" : "A description displayed when the user has no past rituals.", "isCommentAutoGenerated" : true }, - "No ritual yet" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No ritual yet" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aún no hay ritual" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun rituel pour l’instant" - } - } - } - }, "No rituals scheduled for %@." : { "comment" : "A message indicating that there are no rituals scheduled for the current time of day.", "isCommentAutoGenerated" : true @@ -2182,79 +1796,10 @@ "comment" : "The title of the view that appears when a ritual's arc completes.", "isCommentAutoGenerated" : true }, - "Ritual days" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ritual days" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Días de ritual" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jours de rituel" - } - } - } - }, - "Ritual length" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ritual length" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duración del ritual" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Durée du rituel" - } - } - } - }, "Ritual name" : { "comment" : "A label for the name of a ritual.", "isCommentAutoGenerated" : true }, - "Ritual pacing" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ritual pacing" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ritmo del ritual" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rythme du rituel" - } - } - } - }, "Rituals" : { "localizations" : { "en" : { @@ -2329,6 +1874,10 @@ "comment" : "A description below the label \"No Past Rituals\" in the \"Rituals\" view, explaining that users can restart their past rituals.", "isCommentAutoGenerated" : true }, + "Rituals using this category will be set to no category." : { + "comment" : "A message displayed in an alert when deleting a category.", + "isCommentAutoGenerated" : true + }, "Save" : { "comment" : "The text for a button that saves data.", "isCommentAutoGenerated" : true @@ -2395,29 +1944,6 @@ "comment" : "A button label that indicates more content is available.", "isCommentAutoGenerated" : true }, - "Sign in to iCloud to enable sync" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign in to iCloud to enable sync" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inicia sesión en iCloud para activar la sincronización" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connectez-vous à iCloud pour activer la synchronisation" - } - } - } - }, "Sleep Preparation" : { "comment" : "Theme of the \"Sleep Preparation\" ritual preset.", "isCommentAutoGenerated" : true @@ -2502,52 +2028,6 @@ "comment" : "Theme for the \"Morning Hydration\" ritual preset.", "isCommentAutoGenerated" : true }, - "Start your first ritual" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start your first ritual" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Empieza tu primer ritual" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Commence ton premier rituel" - } - } - } - }, - "Steady" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Steady" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Constante" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stable" - } - } - } - }, "Streak" : { "comment" : "Title of a section in the Insight Cards view, related to streaks of consecutive perfect days.", "isCommentAutoGenerated" : true @@ -2604,144 +2084,6 @@ } } }, - "Sync Now" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sync Now" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sincronizar ahora" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchroniser maintenant" - } - } - } - }, - "Sync Settings" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sync Settings" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sincronizar ajustes" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchroniser les réglages" - } - } - } - }, - "Sync settings across all your devices" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sync settings across all your devices" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sincroniza los ajustes en todos tus dispositivos" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchronisez les réglages sur tous vos appareils" - } - } - } - }, - "Synced" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synced" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sincronizado" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchronisé" - } - } - } - }, - "Syncing..." : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Syncing..." - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sincronizando..." - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchronisation..." - } - } - } - }, - "Syncs settings across all your devices via iCloud" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Syncs settings across all your devices via iCloud" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sincroniza los ajustes en todos tus dispositivos mediante iCloud" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchronise les réglages sur tous vos appareils via iCloud" - } - } - } - }, "Take 5 deep breaths" : { "comment" : "Habit title for a ritual preset focused on productivity, encouraging the user to take deep breaths.", "isCommentAutoGenerated" : true @@ -2904,56 +2246,10 @@ "comment" : "A motivational message displayed when the user's completion rate is below 1%.", "isCommentAutoGenerated" : true }, - "Total arcs in motion" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Total arcs in motion" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arcos totales en movimiento" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arcs en cours au total" - } - } - } - }, "Total check-ins" : { "comment" : "Label for a breakdown item showing the total number of check-ins made by the user.", "isCommentAutoGenerated" : true }, - "Total days logged" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Total days logged" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Días totales registrados" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jours consignés au total" - } - } - } - }, "Transition to rest" : { "comment" : "Theme of the \"Evening Wind-Down\" ritual preset.", "isCommentAutoGenerated" : true @@ -3085,29 +2381,6 @@ "comment" : "Tip provided when the user is at their longest streak and it is greater than zero.", "isCommentAutoGenerated" : true }, - "Your active and recent arcs" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your active and recent arcs" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tus arcos activos y recientes" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tes arcs actifs et récents" - } - } - } - }, "Your completion percentage for today across all rituals. The chart shows your last 7 days—this helps you spot patterns and stay consistent." : { "comment" : "Explanation of the insight card that shows the user their completion percentage for today across all their active rituals, with a chart displaying their last 7 days.", "isCommentAutoGenerated" : true diff --git a/Andromida/App/Models/Category.swift b/Andromida/App/Models/Category.swift new file mode 100644 index 0000000..3381c46 --- /dev/null +++ b/Andromida/App/Models/Category.swift @@ -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) + } +} diff --git a/Andromida/App/Models/RitualPresets.swift b/Andromida/App/Models/RitualPresets.swift index 8d58838..ef0de1f 100644 --- a/Andromida/App/Models/RitualPresets.swift +++ b/Andromida/App/Models/RitualPresets.swift @@ -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" diff --git a/Andromida/App/State/CategoryStore.swift b/Andromida/App/State/CategoryStore.swift new file mode 100644 index 0000000..6d1b50a --- /dev/null +++ b/Andromida/App/State/CategoryStore.swift @@ -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 + } +} diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift index ed79f51..3f27211 100644 --- a/Andromida/App/Views/Rituals/RitualDetailView.swift +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -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!) } } diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift index 86b41e6..89857d0 100644 --- a/Andromida/App/Views/Rituals/RitualsView.swift +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -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) } } diff --git a/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift b/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift index 758e193..ed8e10f 100644 --- a/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift @@ -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) } } diff --git a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift index 5cf9a7a..2a7db53 100644 --- a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift @@ -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) - if isUsingCustomCategory { - TextField(String(localized: "Enter custom category"), text: $customCategory) - .textFieldStyle(.roundedBorder) + // 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) @@ -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,16 +388,32 @@ 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) let trimmedTheme = theme.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) } diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index 2e0fc63..52a87c3 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -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 { diff --git a/Andromida/App/Views/Settings/CategoryEditSheet.swift b/Andromida/App/Views/Settings/CategoryEditSheet.swift new file mode 100644 index 0000000..09c321c --- /dev/null +++ b/Andromida/App/Views/Settings/CategoryEditSheet.swift @@ -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!) +} diff --git a/Andromida/App/Views/Settings/CategoryListView.swift b/Andromida/App/Views/Settings/CategoryListView.swift new file mode 100644 index 0000000..cea6c0a --- /dev/null +++ b/Andromida/App/Views/Settings/CategoryListView.swift @@ -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) + } +} diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index 6610e71..ec2c8a0 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -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", diff --git a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift index b6a0a78..8b268e8 100644 --- a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift +++ b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift @@ -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) } diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index 1c94c57..c485cf8 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -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) }