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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
335
Shared/Views/ExportView.swift
Normal file
335
Shared/Views/ExportView.swift
Normal 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) {}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
105
Shared/Views/LockScreenView.swift
Normal file
105
Shared/Views/LockScreenView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
513
Shared/Views/NoteEditorView.swift
Normal file
513
Shared/Views/NoteEditorView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
378
Shared/Views/PhotoPickerView.swift
Normal file
378
Shared/Views/PhotoPickerView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
304
Shared/Views/SettingsView/SettingsTabView.swift
Normal file
304
Shared/Views/SettingsView/SettingsTabView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user