Add premium features and reorganize Settings tab

Premium Features:
- Journal notes and photo attachments for mood entries
- Data export (CSV and PDF reports)
- Privacy lock with Face ID/Touch ID
- Apple Health integration for mood correlation
- 4 new personality packs (Motivational Coach, Zen Master, Best Friend, Data Analyst)

Settings Tab Reorganization:
- Combined Customize and Settings into single tab with segmented control
- Added upgrade banner with trial countdown above segment
- "Why Upgrade?" sheet showing all premium benefits
- Subscribe button opens improved StoreKit 2 subscription view

UI Improvements:
- Enhanced subscription store with feature highlights
- Entry detail view for viewing/editing notes and photos
- Removed duplicate subscription banners from tab content

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-13 12:22:06 -06:00
parent 6c92cf4ec3
commit 920aaee35c
26 changed files with 4295 additions and 99 deletions

View File

@@ -8,8 +8,89 @@
import SwiftUI
import StoreKit
// MARK: - Customize Content View (for use in SettingsTabView)
struct CustomizeContentView: View {
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
var body: some View {
ScrollView {
VStack(spacing: 24) {
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
// Theme
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
Divider()
// Text Color
SettingsRow(title: "Text Color") {
TextColorPickerCompact()
}
}
}
// MOOD STYLE
SettingsSection(title: "Mood Style") {
VStack(spacing: 16) {
// Icon Style
SettingsRow(title: "Icons") {
ImagePackPickerCompact()
}
Divider()
// Mood Colors
SettingsRow(title: "Colors") {
TintPickerCompact()
}
Divider()
// Day View Style
SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact()
}
Divider()
// Voting Layout
SettingsRow(title: "Voting Layout") {
VotingLayoutPickerCompact()
}
}
}
// WIDGETS
SettingsSection(title: "Widgets") {
CustomWidgetSection()
}
// NOTIFICATIONS
SettingsSection(title: "Notifications") {
PersonalityPackPickerCompact()
}
// FILTERS
SettingsSection(title: "Day Filter") {
DayFilterPickerCompact()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 32)
}
.onAppear(perform: {
EventLogger.log(event: "show_customize_view")
})
}
}
// MARK: - Legacy CustomizeView (kept for backwards compatibility)
struct CustomizeView: View {
@State private var showSettings = false
@State private var showSubscriptionStore = false
@EnvironmentObject var iapManager: IAPManager
@Environment(\.colorScheme) private var colorScheme
@@ -27,9 +108,6 @@ struct CustomizeView: View {
SubscriptionBannerView(showSubscriptionStore: $showSubscriptionStore)
.environmentObject(iapManager)
// Preview showing current style
// SampleEntryView()
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
@@ -99,9 +177,6 @@ struct CustomizeView: View {
.onAppear(perform: {
EventLogger.log(event: "show_customize_view")
})
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sheet(isPresented: $showSubscriptionStore) {
FeelsSubscriptionStoreView()
}
@@ -118,14 +193,6 @@ struct CustomizeView: View {
.foregroundColor(textColor)
Spacer()
Button(action: {
showSettings.toggle()
}) {
Image(systemName: "gear")
.font(.system(size: 20))
.foregroundColor(textColor.opacity(0.6))
}
}
.padding(.top, 8)
}

View File

@@ -30,11 +30,11 @@ struct DayView: View {
// MARK: edit row properties
@State private var showingSheet = false
@State private var selectedEntry: MoodEntryModel?
@State private var showEntryDetail = false
//
// MARK: ?? properties
@State private var showTodayInput = true
@State private var showUpdateEntryAlert = false
@StateObject private var onboardingData = OnboardingDataDataManager.shared
@StateObject private var filteredDays = DaysFilterClass.shared
@EnvironmentObject var iapManager: IAPManager
@@ -53,31 +53,25 @@ struct DayView: View {
.sheet(isPresented: $showingSheet) {
SettingsView()
}
.alert(DayViewViewModel.updateTitleHeader(forEntry: selectedEntry),
isPresented: $showUpdateEntryAlert) {
ForEach(Mood.allValues) { mood in
Button(mood.strValue, action: {
if let selectedEntry = selectedEntry {
viewModel.update(entry: selectedEntry, toMood: mood)
.onChange(of: selectedEntry) { _, newEntry in
if newEntry != nil {
showEntryDetail = true
}
}
.sheet(isPresented: $showEntryDetail, onDismiss: {
selectedEntry = nil
}) {
if let entry = selectedEntry {
EntryDetailView(
entry: entry,
onMoodUpdate: { newMood in
viewModel.update(entry: entry, toMood: newMood)
},
onDelete: {
viewModel.update(entry: entry, toMood: .missing)
}
showUpdateEntryAlert = false
selectedEntry = nil
})
)
}
if let selectedEntry = selectedEntry,
deleteEnabled,
selectedEntry.mood != .missing {
Button(String(localized: "content_view_delete_entry"), action: {
viewModel.update(entry: selectedEntry, toMood: Mood.missing)
showUpdateEntryAlert = false
})
}
Button(String(localized: "content_view_fill_in_missing_entry_cancel"), role: .cancel, action: {
selectedEntry = nil
showUpdateEntryAlert = false
})
}
}
.padding([.top])
@@ -182,10 +176,9 @@ extension DayView {
ForEach(filteredEntries, id: \.self) { entry in
EntryListView(entry: entry)
.contentShape(Rectangle())
.onTapGesture(perform: {
.onTapGesture {
selectedEntry = entry
showUpdateEntryAlert = true
})
}
}
}
.padding(.horizontal, 12)
@@ -196,10 +189,9 @@ extension DayView {
ForEach(filteredEntries, id: \.self) { entry in
EntryListView(entry: entry)
.contentShape(Rectangle())
.onTapGesture(perform: {
.onTapGesture {
selectedEntry = entry
showUpdateEntryAlert = true
})
}
}
}
.padding(.horizontal, 12)

View File

@@ -0,0 +1,335 @@
//
// ExportView.swift
// Feels
//
// Export mood data to CSV or PDF formats.
//
import SwiftUI
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@State private var selectedFormat: ExportFormat = .csv
@State private var selectedRange: DateRange = .allTime
@State private var isExporting = false
@State private var exportedURL: URL?
@State private var showShareSheet = false
@State private var showError = false
@State private var errorMessage = ""
private let entries: [MoodEntryModel]
enum ExportFormat: String, CaseIterable {
case csv = "CSV"
case pdf = "PDF"
var icon: String {
switch self {
case .csv: return "tablecells"
case .pdf: return "doc.richtext"
}
}
var description: String {
switch self {
case .csv: return "Spreadsheet format for data analysis"
case .pdf: return "Formatted report with insights"
}
}
}
enum DateRange: String, CaseIterable {
case lastWeek = "Last 7 Days"
case lastMonth = "Last 30 Days"
case last3Months = "Last 3 Months"
case lastYear = "Last Year"
case allTime = "All Time"
var days: Int? {
switch self {
case .lastWeek: return 7
case .lastMonth: return 30
case .last3Months: return 90
case .lastYear: return 365
case .allTime: return nil
}
}
}
init(entries: [MoodEntryModel]) {
self.entries = entries
}
private var filteredEntries: [MoodEntryModel] {
guard let days = selectedRange.days else {
return entries
}
let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date()
return entries.filter { $0.forDate >= cutoffDate }
}
private var validEntries: [MoodEntryModel] {
filteredEntries.filter { ![.missing, .placeholder].contains($0.mood) }
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Preview stats
statsCard
// Format selection
formatSelection
// Date range selection
dateRangeSelection
// Export button
exportButton
}
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Export Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.sheet(isPresented: $showShareSheet) {
if let url = exportedURL {
ExportShareSheet(items: [url])
}
}
.alert("Export Failed", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
Text(errorMessage)
}
}
}
private var statsCard: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "chart.bar.doc.horizontal")
.font(.title2)
.foregroundColor(.accentColor)
Text("Export Preview")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
Divider()
HStack(spacing: 32) {
VStack(spacing: 4) {
Text("\(validEntries.count)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(textColor)
Text("Entries")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(spacing: 4) {
Text(dateRangeText)
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(textColor)
Text("Date Range")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
private var dateRangeText: String {
guard !validEntries.isEmpty else { return "No data" }
let sorted = validEntries.sorted { $0.forDate < $1.forDate }
guard let first = sorted.first, let last = sorted.last else { return "No data" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
if Calendar.current.isDate(first.forDate, equalTo: last.forDate, toGranularity: .day) {
return formatter.string(from: first.forDate)
}
return "\(formatter.string(from: first.forDate)) - \(formatter.string(from: last.forDate))"
}
private var formatSelection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Format")
.font(.headline)
.foregroundColor(textColor)
ForEach(ExportFormat.allCases, id: \.self) { format in
Button {
selectedFormat = format
} label: {
HStack(spacing: 16) {
Image(systemName: format.icon)
.font(.title2)
.frame(width: 44, height: 44)
.background(selectedFormat == format ? Color.accentColor.opacity(0.15) : Color(.systemGray5))
.foregroundColor(selectedFormat == format ? .accentColor : .gray)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(format.rawValue)
.font(.headline)
.foregroundColor(textColor)
Text(format.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if selectedFormat == format {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedFormat == format ? Color.accentColor : Color.clear, lineWidth: 2)
)
)
}
.buttonStyle(.plain)
}
}
}
private var dateRangeSelection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Date Range")
.font(.headline)
.foregroundColor(textColor)
VStack(spacing: 0) {
ForEach(DateRange.allCases, id: \.self) { range in
Button {
selectedRange = range
} label: {
HStack {
Text(range.rawValue)
.foregroundColor(textColor)
Spacer()
if selectedRange == range {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.padding()
.background(Color(.systemBackground))
}
.buttonStyle(.plain)
if range != DateRange.allCases.last {
Divider()
.padding(.leading)
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private var exportButton: some View {
Button {
performExport()
} label: {
HStack(spacing: 8) {
if isExporting {
ProgressView()
.tint(.white)
} else {
Image(systemName: "square.and.arrow.up")
}
Text(isExporting ? "Exporting..." : "Export \(selectedFormat.rawValue)")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(validEntries.isEmpty ? Color.gray : Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(isExporting || validEntries.isEmpty)
.padding(.top, 8)
}
private func performExport() {
isExporting = true
Task {
let url: URL?
switch selectedFormat {
case .csv:
url = ExportService.shared.exportCSV(entries: validEntries)
case .pdf:
url = ExportService.shared.exportPDF(entries: validEntries, title: "Feels Mood Report")
}
await MainActor.run {
isExporting = false
if let url = url {
exportedURL = url
showShareSheet = true
} else {
errorMessage = "Failed to create export file. Please try again."
showError = true
}
}
}
}
}
// MARK: - Export Share Sheet
struct ExportShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -14,25 +14,56 @@ struct FeelsSubscriptionStoreView: View {
var body: some View {
SubscriptionStoreView(groupID: IAPManager.subscriptionGroupID) {
VStack(spacing: 16) {
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundStyle(.pink)
VStack(spacing: 20) {
// App icon or logo
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.pink.opacity(0.3), .orange.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 100, height: 100)
Text(String(localized: "subscription_store_title"))
.font(.title)
.bold()
Image(systemName: "heart.fill")
.font(.system(size: 44))
.foregroundStyle(
LinearGradient(
colors: [.pink, .red],
startPoint: .top,
endPoint: .bottom
)
)
}
Text(String(localized: "subscription_store_subtitle"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 8) {
Text("Unlock Premium")
.font(.system(size: 28, weight: .bold, design: .rounded))
Text("Get unlimited access to all features")
.font(.system(size: 16))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// Feature highlights
VStack(alignment: .leading, spacing: 12) {
FeatureHighlight(icon: "calendar", text: "Month & Year Views")
FeatureHighlight(icon: "lightbulb.fill", text: "AI-Powered Insights")
FeatureHighlight(icon: "photo.fill", text: "Photos & Journal Notes")
FeatureHighlight(icon: "heart.fill", text: "Health Data Correlation")
}
.padding(.top, 8)
}
.padding()
.padding(.horizontal, 20)
.padding(.vertical, 10)
}
.subscriptionStoreControlStyle(.prominentPicker)
.storeButton(.visible, for: .restorePurchases)
.subscriptionStoreButtonLabel(.multiline)
.tint(.pink)
.onInAppPurchaseCompletion { _, result in
if case .success(.success(_)) = result {
dismiss()
@@ -41,6 +72,26 @@ struct FeelsSubscriptionStoreView: View {
}
}
// MARK: - Feature Highlight Row
struct FeatureHighlight: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(.green)
Text(text)
.font(.system(size: 15, weight: .medium))
.foregroundColor(.primary)
Spacer()
}
}
}
#Preview {
FeelsSubscriptionStoreView()
.environmentObject(IAPManager())

View File

@@ -44,6 +44,7 @@ class InsightsViewModel: ObservableObject {
// MARK: - Dependencies
private let insightService = FoundationModelsInsightService()
private let healthService = HealthService.shared
private let calendar = Calendar.current
// MARK: - Initialization
@@ -148,11 +149,28 @@ class InsightsViewModel: ObservableObject {
updateState(.loading)
// Fetch health data if enabled
var healthCorrelations: [HealthCorrelation] = []
if healthService.isEnabled && healthService.isAuthorized {
let healthData = await healthService.fetchHealthData(for: validEntries)
let correlations = healthService.analyzeCorrelations(entries: validEntries, healthData: healthData)
// Convert to HealthCorrelation format
healthCorrelations = correlations.map {
HealthCorrelation(
metric: $0.metric,
insight: $0.insight,
correlation: $0.correlation
)
}
}
do {
let insights = try await insightService.generateInsights(
for: validEntries,
periodName: periodName,
count: 5
count: 5,
healthCorrelations: healthCorrelations
)
updateInsights(insights)
updateState(.loaded)

View File

@@ -0,0 +1,105 @@
//
// LockScreenView.swift
// Feels
//
// Lock screen shown when privacy lock is enabled and app needs authentication.
//
import SwiftUI
struct LockScreenView: View {
@ObservedObject var authManager: BiometricAuthManager
@State private var showError = false
var body: some View {
ZStack {
// Background gradient
LinearGradient(
colors: [
Color(.systemBackground),
Color(.systemGray6)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 40) {
Spacer()
// App icon / lock icon
VStack(spacing: 20) {
Image(systemName: "lock.fill")
.font(.system(size: 60))
.foregroundStyle(.secondary)
Text("Feels is Locked")
.font(.title2)
.fontWeight(.semibold)
Text("Authenticate to access your mood data")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
// Unlock button
Button {
Task {
let success = await authManager.authenticate()
if !success {
showError = true
}
}
} label: {
HStack(spacing: 12) {
Image(systemName: authManager.biometricIcon)
.font(.title2)
Text("Unlock with \(authManager.biometricName)")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(authManager.isAuthenticating)
.padding(.horizontal, 40)
// Passcode fallback hint
if authManager.canUseDevicePasscode {
Text("Or use your device passcode")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
.frame(height: 60)
}
.padding()
}
.alert("Authentication Failed", isPresented: $showError) {
Button("Try Again") {
Task {
await authManager.authenticate()
}
}
Button("Cancel", role: .cancel) { }
} message: {
Text("Unable to verify your identity. Please try again.")
}
.onAppear {
// Auto-trigger authentication on appear
if !authManager.isUnlocked && !authManager.isAuthenticating {
Task {
await authManager.authenticate()
}
}
}
}
}

View File

@@ -20,7 +20,6 @@ struct MainTabView: View {
let monthView: MonthView
let yearView: YearView
let insightsView: InsightsView
let customizeView: CustomizeView
var body: some View {
return TabView {
@@ -44,9 +43,9 @@ struct MainTabView: View {
Label(String(localized: "content_view_tab_insights"), systemImage: "lightbulb.fill")
}
customizeView
SettingsTabView()
.tabItem {
Label(String(localized: "content_view_tab_customize"), systemImage: "pencil")
Label("Settings", systemImage: "gear")
}
}
.accentColor(textColor)
@@ -87,7 +86,6 @@ struct MainTabView_Previews: PreviewProvider {
MainTabView(dayView: DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)),
monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)),
yearView: YearView(viewModel: YearViewModel()),
insightsView: InsightsView(),
customizeView: CustomizeView())
insightsView: InsightsView())
}
}

View File

@@ -0,0 +1,513 @@
//
// NoteEditorView.swift
// Feels
//
// Editor for adding/editing journal notes on mood entries.
//
import SwiftUI
import PhotosUI
struct NoteEditorView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
let entry: MoodEntryModel
@State private var noteText: String
@State private var isSaving = false
@FocusState private var isTextFieldFocused: Bool
private let maxCharacters = 2000
init(entry: MoodEntryModel) {
self.entry = entry
self._noteText = State(initialValue: entry.notes ?? "")
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Entry header
entryHeader
.padding()
.background(Color(.systemGray6))
Divider()
// Notes editor
VStack(alignment: .leading, spacing: 8) {
TextEditor(text: $noteText)
.focused($isTextFieldFocused)
.frame(maxHeight: .infinity)
.scrollContentBackground(.hidden)
.padding(.horizontal, 4)
// Character count
HStack {
Spacer()
Text("\(noteText.count)/\(maxCharacters)")
.font(.caption)
.foregroundStyle(noteText.count > maxCharacters ? .red : .secondary)
}
}
.padding()
}
.navigationTitle("Journal Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveNote()
}
.disabled(isSaving || noteText.count > maxCharacters)
.fontWeight(.semibold)
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
isTextFieldFocused = false
}
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isTextFieldFocused = true
}
}
}
}
private var entryHeader: some View {
HStack(spacing: 12) {
// Mood icon
Circle()
.fill(entry.mood.color.opacity(0.2))
.frame(width: 50, height: 50)
.overlay(
entry.mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(entry.mood.color)
)
VStack(alignment: .leading, spacing: 4) {
Text(entry.forDate, format: .dateTime.weekday(.wide).month().day().year())
.font(.headline)
.foregroundColor(textColor)
Text(entry.moodString)
.font(.subheadline)
.foregroundColor(entry.mood.color)
}
Spacer()
}
}
private func saveNote() {
isSaving = true
let trimmedNote = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
let noteToSave: String? = trimmedNote.isEmpty ? nil : trimmedNote
let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
if success {
dismiss()
} else {
isSaving = false
}
}
}
// MARK: - Entry Detail View (combines mood edit, notes, photos)
struct EntryDetailView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
let entry: MoodEntryModel
let onMoodUpdate: (Mood) -> Void
let onDelete: () -> Void
@State private var showNoteEditor = false
@State private var showPhotoOptions = false
@State private var showPhotoPicker = false
@State private var showCamera = false
@State private var showDeleteConfirmation = false
@State private var showFullScreenPhoto = false
@State private var selectedPhotoItem: PhotosPickerItem?
private var moodColor: Color {
moodTint.color(forMood: entry.mood)
}
private func savePhoto(_ image: UIImage) {
if let photoID = PhotoManager.shared.savePhoto(image) {
_ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: photoID)
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Date header
dateHeader
// Mood section
moodSection
// Notes section
notesSection
// Photo section
photoSection
// Delete button
if deleteEnabled && entry.mood != .missing {
deleteSection
}
}
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Entry Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.sheet(isPresented: $showNoteEditor) {
NoteEditorView(entry: entry)
}
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
Button("Cancel", role: .cancel) { }
} message: {
Text("Are you sure you want to delete this mood entry? This cannot be undone.")
}
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
.onChange(of: selectedPhotoItem) { _, newItem in
guard let newItem else { return }
Task {
if let data = try? await newItem.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
savePhoto(image)
}
selectedPhotoItem = nil
}
}
.fullScreenCover(isPresented: $showCamera) {
CameraView { image in
savePhoto(image)
}
}
.fullScreenCover(isPresented: $showFullScreenPhoto) {
FullScreenPhotoView(photoID: entry.photoID)
}
}
}
private var dateHeader: some View {
VStack(spacing: 8) {
Text(entry.forDate, format: .dateTime.weekday(.wide))
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.month(.wide).day().year())
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
private var moodSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Mood")
.font(.headline)
.foregroundColor(textColor)
// Current mood display
HStack(spacing: 16) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [moodColor.opacity(0.8), moodColor.opacity(0.4)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 60, height: 60)
imagePack.icon(forMood: entry.mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 34, height: 34)
.foregroundColor(.white)
}
.shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
VStack(alignment: .leading, spacing: 4) {
Text(entry.moodString)
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(moodColor)
Text("Tap to change")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
// Mood selection grid
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) {
ForEach(Mood.allValues) { mood in
Button {
onMoodUpdate(mood)
} label: {
VStack(spacing: 6) {
Circle()
.fill(entry.mood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5))
.frame(width: 50, height: 50)
.overlay(
imagePack.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(entry.mood == mood ? .white : .gray)
)
Text(mood.strValue)
.font(.caption2)
.foregroundColor(entry.mood == mood ? moodTint.color(forMood: mood) : .secondary)
}
}
.buttonStyle(.plain)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
}
private var notesSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Journal Note")
.font(.headline)
.foregroundColor(textColor)
Spacer()
Button {
showNoteEditor = true
} label: {
Text(entry.notes == nil ? "Add" : "Edit")
.font(.subheadline)
.fontWeight(.medium)
}
}
Button {
showNoteEditor = true
} label: {
HStack {
if let notes = entry.notes, !notes.isEmpty {
Text(notes)
.font(.body)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(5)
} else {
HStack(spacing: 8) {
Image(systemName: "note.text")
.foregroundStyle(.secondary)
Text("Add a note about how you're feeling...")
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
.buttonStyle(.plain)
}
}
private var photoSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Photo")
.font(.headline)
.foregroundColor(textColor)
Spacer()
Button {
showPhotoOptions = true
} label: {
Text(entry.photoID == nil ? "Add" : "Change")
.font(.subheadline)
.fontWeight(.medium)
}
}
.zIndex(1)
if let photoID = entry.photoID,
let image = PhotoManager.shared.thumbnail(for: photoID) {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 200)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 16))
.clipped()
.contentShape(Rectangle())
.onTapGesture {
showFullScreenPhoto = true
}
} else {
Button {
showPhotoOptions = true
} label: {
VStack(spacing: 12) {
Image(systemName: "photo.badge.plus")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("Add a photo")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 120)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [8]))
.foregroundStyle(.tertiary)
)
)
}
.buttonStyle(.plain)
}
}
.confirmationDialog("Photo", isPresented: $showPhotoOptions, titleVisibility: .visible) {
Button("Take Photo") {
showCamera = true
}
Button("Choose from Library") {
showPhotoPicker = true
}
if entry.photoID != nil {
Button("Remove Photo", role: .destructive) {
PhotoManager.shared.deletePhoto(id: entry.photoID!)
_ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: nil)
}
}
Button("Cancel", role: .cancel) { }
}
}
private var deleteSection: some View {
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete Entry")
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
.padding(.top, 8)
}
}
// MARK: - Full Screen Photo View
struct FullScreenPhotoView: View {
@Environment(\.dismiss) private var dismiss
let photoID: UUID?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let photoID = photoID,
let image = PhotoManager.shared.loadPhoto(id: photoID) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.ignoresSafeArea()
}
}
.overlay(alignment: .topTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundStyle(.white.opacity(0.8))
.padding()
}
}
.onTapGesture {
dismiss()
}
}
}

View File

@@ -0,0 +1,378 @@
//
// PhotoPickerView.swift
// Feels
//
// Photo picker and gallery for mood entry attachments.
//
import SwiftUI
import PhotosUI
// MARK: - Photo Picker View
struct PhotoPickerView: View {
@Environment(\.dismiss) private var dismiss
let entry: MoodEntryModel
let onPhotoSelected: (UIImage) -> Void
@State private var selectedItem: PhotosPickerItem?
@State private var showCamera = false
@State private var isProcessing = false
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 50))
.foregroundStyle(.secondary)
Text("Add a Photo")
.font(.title2)
.fontWeight(.semibold)
Text("Capture how you're feeling today")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 40)
Spacer()
// Options
VStack(spacing: 16) {
// Photo Library
PhotosPicker(selection: $selectedItem, matching: .images) {
HStack(spacing: 16) {
Image(systemName: "photo.stack")
.font(.title2)
.frame(width: 44, height: 44)
.background(Color.blue.opacity(0.15))
.foregroundColor(.blue)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text("Photo Library")
.font(.headline)
.foregroundColor(.primary)
Text("Choose from your photos")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
.disabled(isProcessing)
// Camera
Button {
showCamera = true
} label: {
HStack(spacing: 16) {
Image(systemName: "camera")
.font(.title2)
.frame(width: 44, height: 44)
.background(Color.green.opacity(0.15))
.foregroundColor(.green)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text("Take Photo")
.font(.headline)
.foregroundColor(.primary)
Text("Use your camera")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
.disabled(isProcessing)
}
.padding(.horizontal)
Spacer()
// Loading indicator
if isProcessing {
ProgressView("Processing...")
.padding()
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Add Photo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.onChange(of: selectedItem) { _, newItem in
Task {
await loadImage(from: newItem)
}
}
.fullScreenCover(isPresented: $showCamera) {
CameraView { image in
handleSelectedImage(image)
}
}
}
}
private func loadImage(from item: PhotosPickerItem?) async {
guard let item = item else { return }
isProcessing = true
defer { isProcessing = false }
do {
if let data = try await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
handleSelectedImage(image)
}
} catch {
print("PhotoPickerView: Failed to load image: \(error)")
}
}
private func handleSelectedImage(_ image: UIImage) {
onPhotoSelected(image)
dismiss()
}
}
// MARK: - Camera View
struct CameraView: UIViewControllerRepresentable {
@Environment(\.dismiss) private var dismiss
let onImageCaptured: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraView
init(_ parent: CameraView) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage {
parent.onImageCaptured(image)
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}
// MARK: - Photo Gallery View (Full screen photo viewer)
struct PhotoGalleryView: View {
@Environment(\.dismiss) private var dismiss
let photoID: UUID
let onDelete: () -> Void
@State private var showDeleteConfirmation = false
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
Color.black.ignoresSafeArea()
if let image = PhotoManager.shared.image(for: photoID) {
image
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(offset)
.gesture(
MagnificationGesture()
.onChanged { value in
scale = lastScale * value
}
.onEnded { _ in
lastScale = scale
if scale < 1 {
withAnimation {
scale = 1
lastScale = 1
offset = .zero
lastOffset = .zero
}
}
}
)
.simultaneousGesture(
DragGesture()
.onChanged { value in
if scale > 1 {
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
}
.onEnded { _ in
lastOffset = offset
}
)
.onTapGesture(count: 2) {
withAnimation {
if scale > 1 {
scale = 1
lastScale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
lastScale = 2
}
}
}
} else {
VStack(spacing: 16) {
Image(systemName: "photo.badge.exclamationmark")
.font(.system(size: 50))
.foregroundColor(.gray)
Text("Photo not found")
.foregroundColor(.gray)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
sharePhoto()
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
}
}
.confirmationDialog("Delete Photo", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
Button("Cancel", role: .cancel) { }
} message: {
Text("Are you sure you want to delete this photo?")
}
}
}
private func sharePhoto() {
guard let uiImage = PhotoManager.shared.loadPhoto(id: photoID) else { return }
let activityVC = UIActivityViewController(
activityItems: [uiImage],
applicationActivities: nil
)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(activityVC, animated: true)
}
}
}
// MARK: - Photo Thumbnail View (for list display)
struct PhotoThumbnailView: View {
let photoID: UUID?
let size: CGFloat
var body: some View {
if let photoID = photoID,
let image = PhotoManager.shared.thumbnail(for: photoID) {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: size, height: size)
.overlay(
Image(systemName: "photo")
.foregroundStyle(.tertiary)
)
}
}
}

View File

@@ -0,0 +1,304 @@
//
// SettingsTabView.swift
// Feels (iOS)
//
// Created by Trey Tartt on 12/13/25.
//
import SwiftUI
import StoreKit
enum SettingsTab: String, CaseIterable {
case customize = "Customize"
case settings = "Settings"
}
struct SettingsTabView: View {
@State private var selectedTab: SettingsTab = .customize
@State private var showWhyUpgrade = false
@State private var showSubscriptionStore = false
@EnvironmentObject var authManager: BiometricAuthManager
@EnvironmentObject var iapManager: IAPManager
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
VStack(spacing: 0) {
// Header
Text("Settings")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(textColor)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 8)
// Upgrade Banner (only show if not subscribed)
if !iapManager.isSubscribed {
UpgradeBannerView(
showWhyUpgrade: $showWhyUpgrade,
showSubscriptionStore: $showSubscriptionStore,
trialExpirationDate: iapManager.trialExpirationDate
)
.padding(.horizontal, 16)
.padding(.top, 12)
}
// Segmented control
Picker("", selection: $selectedTab) {
ForEach(SettingsTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 16)
// Content based on selected tab
if selectedTab == .customize {
CustomizeContentView()
} else {
SettingsContentView()
.environmentObject(authManager)
}
}
.background(
theme.currentTheme.bg
.edgesIgnoringSafeArea(.all)
)
.sheet(isPresented: $showWhyUpgrade) {
WhyUpgradeView()
}
.sheet(isPresented: $showSubscriptionStore) {
FeelsSubscriptionStoreView()
.environmentObject(iapManager)
}
}
}
// MARK: - Upgrade Banner View
struct UpgradeBannerView: View {
@Binding var showWhyUpgrade: Bool
@Binding var showSubscriptionStore: Bool
let trialExpirationDate: Date?
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
VStack(spacing: 12) {
// Countdown timer
HStack(spacing: 6) {
Image(systemName: "clock")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.orange)
if let expirationDate = trialExpirationDate {
Text("Trial expires in ")
.font(.system(size: 14, weight: .medium))
.foregroundColor(textColor.opacity(0.8))
+
Text(expirationDate, style: .relative)
.font(.system(size: 14, weight: .bold))
.foregroundColor(.orange)
} else {
Text("Trial expired")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.orange)
}
}
// Buttons in HStack
HStack(spacing: 12) {
// Why Upgrade button
Button {
showWhyUpgrade = true
} label: {
Text("Why Upgrade?")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.accentColor)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.accentColor, lineWidth: 1.5)
)
}
// Subscribe button
Button {
showSubscriptionStore = true
} label: {
Text("Subscribe")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.pink)
)
}
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemGray6).opacity(0.5))
)
}
}
// MARK: - Why Upgrade View
struct WhyUpgradeView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.system(size: 50))
.foregroundStyle(
LinearGradient(
colors: [.orange, .pink],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text("Unlock Premium")
.font(.system(size: 28, weight: .bold))
Text("Get the most out of your mood tracking journey")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 20)
// Benefits list
VStack(spacing: 16) {
PremiumBenefitRow(
icon: "calendar",
iconColor: .blue,
title: "Month View",
description: "See your mood patterns across entire months at a glance"
)
PremiumBenefitRow(
icon: "chart.bar.fill",
iconColor: .green,
title: "Year View",
description: "Track long-term trends and see how your mood evolves over time"
)
PremiumBenefitRow(
icon: "lightbulb.fill",
iconColor: .yellow,
title: "AI Insights",
description: "Get personalized insights and patterns discovered by AI"
)
PremiumBenefitRow(
icon: "note.text",
iconColor: .purple,
title: "Journal Notes",
description: "Add notes and context to your mood entries"
)
PremiumBenefitRow(
icon: "photo.fill",
iconColor: .pink,
title: "Photo Attachments",
description: "Capture moments with photos attached to entries"
)
PremiumBenefitRow(
icon: "heart.fill",
iconColor: .red,
title: "Health Integration",
description: "Correlate mood with steps, sleep, and exercise data"
)
PremiumBenefitRow(
icon: "square.and.arrow.up",
iconColor: .orange,
title: "Export Data",
description: "Export your data as CSV or beautiful PDF reports"
)
PremiumBenefitRow(
icon: "faceid",
iconColor: .gray,
title: "Privacy Lock",
description: "Protect your data with Face ID or Touch ID"
)
}
.padding(.horizontal, 20)
}
.padding(.bottom, 40)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
// MARK: - Premium Benefit Row
struct PremiumBenefitRow: View {
let icon: String
let iconColor: Color
let title: String
let description: String
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 22))
.foregroundColor(iconColor)
.frame(width: 44, height: 44)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(iconColor.opacity(0.15))
)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 16, weight: .semibold))
Text(description)
.font(.system(size: 14))
.foregroundColor(.secondary)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
}
}
struct SettingsTabView_Previews: PreviewProvider {
static var previews: some View {
SettingsTabView()
.environmentObject(BiometricAuthManager())
.environmentObject(IAPManager())
}
}

View File

@@ -10,26 +10,332 @@ import CloudKitSyncMonitor
import UniformTypeIdentifiers
import StoreKit
// MARK: - Settings Content View (for use in SettingsTabView)
struct SettingsContentView: View {
@EnvironmentObject var authManager: BiometricAuthManager
@State private var showOnboarding = false
@State private var showExportView = false
@StateObject private var healthService = HealthService.shared
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
ScrollView {
VStack {
// Features section
featuresSectionHeader
privacyLockToggle
healthKitToggle
exportDataButton
// Settings section
settingsSectionHeader
canDelete
showOnboardingButton
// Legal section
legalSectionHeader
eulaButton
privacyButton
Spacer()
.frame(height: 20)
Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))")
.font(.body)
.foregroundColor(.secondary)
}
.padding()
}
.sheet(isPresented: $showOnboarding) {
OnboardingMain(onboardingData: UserDefaultsStore.getOnboarding(),
updateBoardingDataClosure: { onboardingData in
OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData)
showOnboarding = false
})
}
.sheet(isPresented: $showExportView) {
ExportView(entries: DataController.shared.getData(
startDate: Date(timeIntervalSince1970: 0),
endDate: Date(),
includedDays: []
))
}
.onAppear(perform: {
EventLogger.log(event: "show_settings_view")
})
}
// MARK: - Section Headers
private var featuresSectionHeader: some View {
HStack {
Text("Features")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
.padding(.top, 20)
.padding(.horizontal, 4)
}
private var settingsSectionHeader: some View {
HStack {
Text("Settings")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
.padding(.top, 20)
.padding(.horizontal, 4)
}
private var legalSectionHeader: some View {
HStack {
Text("Legal")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
.padding(.top, 20)
.padding(.horizontal, 4)
}
// MARK: - Privacy Lock Toggle
@ViewBuilder
private var privacyLockToggle: some View {
if authManager.canUseBiometrics {
ZStack {
theme.currentTheme.secondaryBGColor
HStack(spacing: 12) {
Image(systemName: authManager.biometricIcon)
.font(.title2)
.foregroundColor(.accentColor)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Privacy Lock")
.foregroundColor(textColor)
Text("Require \(authManager.biometricName) to open app")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: Binding(
get: { authManager.isLockEnabled },
set: { newValue in
Task {
if newValue {
let success = await authManager.enableLock()
if !success {
EventLogger.log(event: "privacy_lock_enable_failed")
}
} else {
authManager.disableLock()
}
}
}
))
.labelsHidden()
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
}
// MARK: - Health Kit Toggle
private var healthKitToggle: some View {
ZStack {
theme.currentTheme.secondaryBGColor
HStack(spacing: 12) {
Image(systemName: "heart.fill")
.font(.title2)
.foregroundColor(.red)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Apple Health")
.foregroundColor(textColor)
Text("Correlate mood with health data")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if healthService.isAvailable {
Toggle("", isOn: Binding(
get: { healthService.isEnabled },
set: { newValue in
if newValue {
Task {
let success = await healthService.requestAuthorization()
if !success {
EventLogger.log(event: "healthkit_enable_failed")
}
}
} else {
healthService.isEnabled = false
EventLogger.log(event: "healthkit_disabled")
}
}
))
.labelsHidden()
} else {
Text("Not Available")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
// MARK: - Export Data Button
private var exportDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button(action: {
EventLogger.log(event: "tap_export_data")
showExportView = true
}, label: {
HStack(spacing: 12) {
Image(systemName: "square.and.arrow.up")
.font(.title2)
.foregroundColor(.accentColor)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Export Data")
.foregroundColor(textColor)
Text("CSV or PDF report")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding()
})
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var showOnboardingButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button(action: {
EventLogger.log(event: "tap_show_onboarding")
showOnboarding.toggle()
}, label: {
Text(String(localized: "settings_view_show_onboarding"))
.foregroundColor(textColor)
})
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var canDelete: some View {
ZStack {
theme.currentTheme.secondaryBGColor
VStack {
Toggle(String(localized: "settings_use_delete_enable"),
isOn: $deleteEnabled)
.onChange(of: deleteEnabled) { _, newValue in
EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue])
}
.foregroundColor(textColor)
.padding()
}
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var eulaButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button(action: {
EventLogger.log(event: "show_eula")
if let url = URL(string: "https://ifeels.app/eula.html") {
UIApplication.shared.open(url)
}
}, label: {
Text(String(localized: "settings_view_show_eula"))
.foregroundColor(textColor)
})
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var privacyButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button(action: {
EventLogger.log(event: "show_privacy")
if let url = URL(string: "https://ifeels.app/privacy.html") {
UIApplication.shared.open(url)
}
}, label: {
Text(String(localized: "settings_view_show_privacy"))
.foregroundColor(textColor)
})
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
}
// MARK: - Legacy SettingsView (sheet presentation with close button)
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@EnvironmentObject var iapManager: IAPManager
@EnvironmentObject var authManager: BiometricAuthManager
@State private var showingExporter = false
@State private var showingImporter = false
@State private var importContent = ""
@State private var showOnboarding = false
@State private var showExportView = false
@State private var showSpecialThanks = false
@State private var showWhyBGMode = false
@ObservedObject var syncMonitor = SyncMonitor.shared
@StateObject private var healthService = HealthService.shared
@AppStorage(UserDefaultsStore.Keys.useCloudKit.rawValue, store: GroupUserDefaults.groupDefaults) private var useCloudKit = false
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
var body: some View {
ScrollView {
@@ -37,11 +343,23 @@ struct SettingsView: View {
Group {
closeButtonView
.padding()
// cloudKitEnable
subscriptionInfoView
// Features section
featuresSectionHeader
privacyLockToggle
healthKitToggle
exportDataButton
// Settings section
settingsSectionHeader
canDelete
showOnboardingButton
// Legal section
legalSectionHeader
eulaButton
privacyButton
// specialThanksCell
@@ -78,6 +396,13 @@ struct SettingsView: View {
showOnboarding = false
})
}
.sheet(isPresented: $showExportView) {
ExportView(entries: DataController.shared.getData(
startDate: Date(timeIntervalSince1970: 0),
endDate: Date(),
includedDays: []
))
}
.onAppear(perform: {
EventLogger.log(event: "show_settings_view")
})
@@ -146,6 +471,178 @@ struct SettingsView: View {
private var subscriptionInfoView: some View {
PurchaseButtonView(iapManager: iapManager)
}
// MARK: - Section Headers
private var featuresSectionHeader: some View {
HStack {
Text("Features")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
.padding(.top, 20)
.padding(.horizontal, 4)
}
private var settingsSectionHeader: some View {
HStack {
Text("Settings")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
.padding(.top, 20)
.padding(.horizontal, 4)
}
private var legalSectionHeader: some View {
HStack {
Text("Legal")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
.padding(.top, 20)
.padding(.horizontal, 4)
}
// MARK: - Privacy Lock Toggle
@ViewBuilder
private var privacyLockToggle: some View {
if authManager.canUseBiometrics {
ZStack {
theme.currentTheme.secondaryBGColor
HStack(spacing: 12) {
Image(systemName: authManager.biometricIcon)
.font(.title2)
.foregroundColor(.accentColor)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Privacy Lock")
.foregroundColor(textColor)
Text("Require \(authManager.biometricName) to open app")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: Binding(
get: { authManager.isLockEnabled },
set: { newValue in
Task {
if newValue {
let success = await authManager.enableLock()
if !success {
EventLogger.log(event: "privacy_lock_enable_failed")
}
} else {
authManager.disableLock()
}
}
}
))
.labelsHidden()
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
}
// MARK: - Health Kit Toggle
private var healthKitToggle: some View {
ZStack {
theme.currentTheme.secondaryBGColor
HStack(spacing: 12) {
Image(systemName: "heart.fill")
.font(.title2)
.foregroundColor(.red)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Apple Health")
.foregroundColor(textColor)
Text("Correlate mood with health data")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if healthService.isAvailable {
Toggle("", isOn: Binding(
get: { healthService.isEnabled },
set: { newValue in
if newValue {
Task {
let success = await healthService.requestAuthorization()
if !success {
EventLogger.log(event: "healthkit_enable_failed")
}
}
} else {
healthService.isEnabled = false
EventLogger.log(event: "healthkit_disabled")
}
}
))
.labelsHidden()
} else {
Text("Not Available")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
// MARK: - Export Data Button
private var exportDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button(action: {
EventLogger.log(event: "tap_export_data")
showExportView = true
}, label: {
HStack(spacing: 12) {
Image(systemName: "square.and.arrow.up")
.font(.title2)
.foregroundColor(.accentColor)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Export Data")
.foregroundColor(textColor)
Text("CSV or PDF report")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding()
})
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var closeButtonView: some View {
HStack{