diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index 030b059..b4b6243 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -7,6 +7,22 @@ import Foundation +enum VotingLayoutStyle: Int, CaseIterable { + case horizontal = 0 // Current: 5 buttons in a row + case cards = 1 // Larger tappable cards with labels + case radial = 2 // Semi-circle/wheel arrangement + case stacked = 3 // Full-width vertical list + + var displayName: String { + switch self { + case .horizontal: return "Horizontal" + case .cards: return "Cards" + case .radial: return "Radial" + case .stacked: return "Stacked" + } + } +} + class UserDefaultsStore { enum Keys: String { case savedOnboardingData @@ -28,6 +44,7 @@ class UserDefaultsStore { case firstLaunchDate case hasActiveSubscription case lastVotedDate + case votingLayoutStyle case contentViewCurrentSelectedHeaderViewBackDays case contentViewHeaderTag diff --git a/Shared/Views/AddMoodHeaderView.swift b/Shared/Views/AddMoodHeaderView.swift index 93a25aa..72fc21b 100644 --- a/Shared/Views/AddMoodHeaderView.swift +++ b/Shared/Views/AddMoodHeaderView.swift @@ -6,7 +6,6 @@ // import Foundation - import SwiftUI import CoreData @@ -14,72 +13,252 @@ struct AddMoodHeaderView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0 @State var onboardingData = OnboardingDataDataManager.shared.savedOnboardingData - + let addItemHeaderClosure: ((Mood, Date) -> Void) - + init(addItemHeaderClosure: @escaping ((Mood, Date) -> Void)) { self.addItemHeaderClosure = addItemHeaderClosure } - + + private var layoutStyle: VotingLayoutStyle { + VotingLayoutStyle(rawValue: votingLayoutStyle) ?? .horizontal + } + var body: some View { ZStack { theme.currentTheme.secondaryBGColor - - VStack { + + VStack(spacing: 16) { Text(ShowBasedOnVoteLogics.getVotingTitle(onboardingData: onboardingData)) - .font(.title) + .font(.title2.bold()) .foregroundColor(textColor) - .padding() - HStack{ - ForEach(Mood.allValues) { mood in - VStack { - Button(action: { - addItem(withMood: mood) - }, label: { - mood.icon - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: CGFloat(50), height: CGFloat(50), alignment: .center) - .foregroundColor(moodTint.color(forMood: mood)) - }) - - //Text(mood.strValue) - }.frame(minWidth: 0, maxWidth: .infinity) - } - } + .padding(.top) + + votingLayoutContent + .padding(.bottom) } - .padding([.leading, .trailing, .bottom]) + .padding(.horizontal) } .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) - .frame(minHeight: 88, maxHeight: 150) .frame(minWidth: 0, maxWidth: .infinity) } - + + @ViewBuilder + private var votingLayoutContent: some View { + switch layoutStyle { + case .horizontal: + HorizontalVotingView(moodTint: moodTint, onMoodSelected: addItem) + case .cards: + CardVotingView(moodTint: moodTint, onMoodSelected: addItem) + case .radial: + RadialVotingView(moodTint: moodTint, onMoodSelected: addItem) + case .stacked: + StackedVotingView(moodTint: moodTint, onMoodSelected: addItem) + } + } + private func addItem(withMood mood: Mood) { + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + let date = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData) addItemHeaderClosure(mood, date) } } +// MARK: - Layout 1: Horizontal (Polished version of current) +struct HorizontalVotingView: View { + let moodTint: MoodTints + let onMoodSelected: (Mood) -> Void + + var body: some View { + HStack(spacing: 8) { + ForEach(Mood.allValues) { mood in + Button(action: { onMoodSelected(mood) }) { + mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 55, height: 55) + .foregroundColor(moodTint.color(forMood: mood)) + } + .buttonStyle(MoodButtonStyle()) + .frame(maxWidth: .infinity) + } + } + } +} + +// MARK: - Layout 2: Cards Grid +struct CardVotingView: View { + let moodTint: MoodTints + let onMoodSelected: (Mood) -> Void + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(Mood.allValues) { mood in + Button(action: { onMoodSelected(mood) }) { + VStack(spacing: 8) { + mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .foregroundColor(moodTint.color(forMood: mood)) + + Text(mood.strValue) + .font(.caption.weight(.medium)) + .foregroundColor(moodTint.color(forMood: mood)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(moodTint.color(forMood: mood).opacity(0.15)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(moodTint.color(forMood: mood).opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(CardButtonStyle()) + } + } + } +} + +// MARK: - Layout 3: Radial/Semi-circle +struct RadialVotingView: View { + let moodTint: MoodTints + let onMoodSelected: (Mood) -> Void + + var body: some View { + GeometryReader { geometry in + let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height * 0.9) + let radius = min(geometry.size.width, geometry.size.height) * 0.65 + let moods = Mood.allValues + + ZStack { + ForEach(Array(moods.enumerated()), id: \.element.id) { index, mood in + let angle = angleForIndex(index, total: moods.count) + let position = positionForAngle(angle, radius: radius, center: center) + + Button(action: { onMoodSelected(mood) }) { + VStack(spacing: 4) { + mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 44, height: 44) + .foregroundColor(moodTint.color(forMood: mood)) + + Text(mood.strValue) + .font(.caption2.weight(.medium)) + .foregroundColor(moodTint.color(forMood: mood)) + } + .padding(8) + .background( + Circle() + .fill(moodTint.color(forMood: mood).opacity(0.1)) + ) + } + .buttonStyle(MoodButtonStyle()) + .position(position) + } + } + } + .frame(height: 180) + } + + private func angleForIndex(_ index: Int, total: Int) -> Double { + // Spread moods across a semi-circle (180 degrees), from left to right + let startAngle = Double.pi // 180 degrees (left) + let endAngle = 0.0 // 0 degrees (right) + let step = (startAngle - endAngle) / Double(total - 1) + return startAngle - (step * Double(index)) + } + + private func positionForAngle(_ angle: Double, radius: CGFloat, center: CGPoint) -> CGPoint { + CGPoint( + x: center.x + radius * CGFloat(cos(angle)), + y: center.y - radius * CGFloat(sin(angle)) + ) + } +} + +// MARK: - Layout 4: Stacked Full-width +struct StackedVotingView: View { + let moodTint: MoodTints + let onMoodSelected: (Mood) -> Void + + var body: some View { + VStack(spacing: 10) { + ForEach(Mood.allValues) { mood in + Button(action: { onMoodSelected(mood) }) { + HStack(spacing: 16) { + mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .foregroundColor(moodTint.color(forMood: mood)) + + Text(mood.strValue) + .font(.body.weight(.semibold)) + .foregroundColor(moodTint.color(forMood: mood)) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(moodTint.color(forMood: mood).opacity(0.5)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(moodTint.color(forMood: mood).opacity(0.12)) + ) + } + .buttonStyle(CardButtonStyle()) + } + } + } +} + +// MARK: - Button Styles +struct MoodButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.9 : 1.0) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + +struct CardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.96 : 1.0) + .opacity(configuration.isPressed ? 0.8 : 1.0) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + +// MARK: - Previews struct AddMoodHeaderView_Previews: PreviewProvider { static var previews: some View { Group { AddMoodHeaderView(addItemHeaderClosure: { (_,_) in - + }).environment(\.managedObjectContext, PersistenceController.shared.viewContext) - + AddMoodHeaderView(addItemHeaderClosure: { (_,_) in - - }).preferredColorScheme(.dark).environment(\.managedObjectContext, PersistenceController.shared.viewContext) - - AddMoodHeaderView(addItemHeaderClosure: { (_,_) in - - }).environment(\.managedObjectContext, PersistenceController.shared.viewContext) - - AddMoodHeaderView(addItemHeaderClosure: { (_,_) in - + }).preferredColorScheme(.dark).environment(\.managedObjectContext, PersistenceController.shared.viewContext) } } diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index c529591..f0dc4b9 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -21,6 +21,8 @@ struct CustomizeView: View { IconPickerView() ThemePickerView() Divider() + VotingLayoutPickerView() + Divider() SampleEntryView() ImagePackPickerView() } diff --git a/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift new file mode 100644 index 0000000..a69820c --- /dev/null +++ b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift @@ -0,0 +1,118 @@ +// +// VotingLayoutPickerView.swift +// Feels (iOS) +// +// Created by Claude Code on 12/9/24. +// + +import SwiftUI + +struct VotingLayoutPickerView: View { + @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.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0 + + private var currentLayout: VotingLayoutStyle { + VotingLayoutStyle(rawValue: votingLayoutStyle) ?? .horizontal + } + + var body: some View { + ZStack { + theme.currentTheme.secondaryBGColor + + VStack(alignment: .leading, spacing: 12) { + Text("Voting Layout") + .font(.headline) + .foregroundColor(textColor) + .padding(.horizontal) + .padding(.top) + + HStack(spacing: 8) { + ForEach(VotingLayoutStyle.allCases, id: \.rawValue) { layout in + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + votingLayoutStyle = layout.rawValue + } + EventLogger.log(event: "change_voting_layout", withData: ["layout": layout.displayName]) + }) { + VStack(spacing: 6) { + layoutIcon(for: layout) + .frame(width: 44, height: 44) + .foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.6)) + + Text(layout.displayName) + .font(.caption) + .foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.8)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(currentLayout == layout ? Color.accentColor.opacity(0.15) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(currentLayout == layout ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom) + } + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + + @ViewBuilder + private func layoutIcon(for layout: VotingLayoutStyle) -> some View { + switch layout { + case .horizontal: + HStack(spacing: 4) { + ForEach(0..<5) { _ in + Circle() + .frame(width: 6, height: 6) + } + } + case .cards: + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 3) { + ForEach(0..<6) { _ in + RoundedRectangle(cornerRadius: 2) + .frame(width: 10, height: 12) + } + } + case .radial: + ZStack { + ForEach(0..<5) { index in + Circle() + .frame(width: 6, height: 6) + .offset(radialOffset(index: index, total: 5, radius: 16)) + } + } + case .stacked: + VStack(spacing: 3) { + ForEach(0..<4) { _ in + RoundedRectangle(cornerRadius: 2) + .frame(width: 32, height: 6) + } + } + } + } + + private func radialOffset(index: Int, total: Int, radius: CGFloat) -> CGSize { + let angle = Double.pi - (Double.pi * Double(index) / Double(total - 1)) + return CGSize( + width: radius * CGFloat(cos(angle)), + height: -radius * CGFloat(sin(angle)) + 4 + ) + } +} + +struct VotingLayoutPickerView_Previews: PreviewProvider { + static var previews: some View { + VotingLayoutPickerView() + .padding() + } +} diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift index c60df03..e45e78d 100644 --- a/Shared/Views/DayView/DayView.swift +++ b/Shared/Views/DayView/DayView.swift @@ -16,37 +16,37 @@ struct DayViewConstants { struct DayView: View { @Environment(\.managedObjectContext) private var viewContext - + @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.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 - + // store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 - + // MARK: edit row properties @State private var showingSheet = false @State private var selectedEntry: MoodEntry? // - + // 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 - + @ObservedObject var viewModel: DayViewViewModel - + var body: some View { ZStack { Text(String(customMoodTintUpdateNumber)) .hidden() - + mainView .onAppear(perform: { EventLogger.log(event: "show_home_view") @@ -65,7 +65,7 @@ struct DayView: View { selectedEntry = nil }) } - + if let selectedEntry = selectedEntry, deleteEnabled, selectedEntry.mood != .missing { @@ -74,7 +74,7 @@ struct DayView: View { showUpdateEntryAlert = false }) } - + Button(String(localized: "content_view_fill_in_missing_entry_cancel"), role: .cancel, action: { selectedEntry = nil showUpdateEntryAlert = false @@ -83,21 +83,19 @@ struct DayView: View { } .padding([.top]) } - - + + // MARK: Views public var mainView: some View { - VStack { + VStack(spacing: 12) { if viewModel.hasNoData { Spacer() EmptyHomeView(showVote: true, viewModel: viewModel) Spacer() } else { - VStack { - headerView - - listView - } + headerView + + listView } } .padding([.leading, .trailing]) @@ -109,7 +107,7 @@ struct DayView: View { theme.currentTheme.bg ) } - + private var headerView: some View { VStack { if ShowBasedOnVoteLogics.isMissingCurrentVote(onboardingData: UserDefaultsStore.getOnboarding()) { @@ -119,14 +117,14 @@ struct DayView: View { } } } - + private var listView: some View { ScrollView { - LazyVStack(spacing: 5, pinnedViews: [.sectionHeaders]) { + LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) { ForEach(viewModel.grouped.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in - + // for reach month ForEach(months.sorted(by: { $0.key > $1.key @@ -137,12 +135,7 @@ struct DayView: View { } } } - .background( - GeometryReader { proxy in - let offset = proxy.frame(in: .named("scroll")).minY - Color.clear.preference(key: ViewOffsetKey.self, value: offset) - } - ) + .padding(.bottom, 20) } .background( theme.currentTheme.secondaryBGColor @@ -154,24 +147,24 @@ struct DayView: View { // view that make up the list body extension DayView { private func SectionHeaderView(month: Int, year: Int) -> some View { - Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") - .font(.title) - .foregroundColor(textColor) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background( - theme.currentTheme.secondaryBGColor - ) + HStack { + Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") + .font(.title2.bold()) + .foregroundColor(textColor) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial) } - + private func monthListView(month: Int, year: Int, entries: [MoodEntry]) -> some View { - VStack { + VStack(spacing: 8) { // for reach all entries ForEach(entries.sorted(by: { return $0.forDate! > $1.forDate! }), id: \.self) { entry in if filteredDays.currentFilters.contains(Int(entry.weekDay)) { - // let _ = print(entry.forDate, entry.weekDay, filteredDays.currentFilters) EntryListView(entry: entry) .contentShape(Rectangle()) .onTapGesture(perform: { @@ -181,6 +174,7 @@ extension DayView { } } } + .padding(.horizontal, 12) } } @@ -198,7 +192,7 @@ struct DayView_Previews: PreviewProvider { .onAppear(perform: { PersistenceController.shared.populateMemory() }) - + DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)) .preferredColorScheme(.dark) .environment(\.managedObjectContext, PersistenceController.shared.viewContext) diff --git a/Shared/Views/EntryListView.swift b/Shared/Views/EntryListView.swift index d774866..e86f3b8 100644 --- a/Shared/Views/EntryListView.swift +++ b/Shared/Views/EntryListView.swift @@ -11,46 +11,57 @@ struct EntryListView: View { @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 - + public let entry: MoodEntry - + + private var moodColor: Color { + moodTint.color(forMood: entry.mood) + } + var body: some View { - HStack { + HStack(spacing: 14) { imagePack.icon(forMood: entry.mood) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40, alignment: .center) - .foregroundColor(moodTint.color(forMood: entry.mood)) - .padding(.leading, 15) - - VStack { - HStack { - Text(Random.weekdayName(fromDate:entry.forDate!)) - .font(.title3) + .frame(width: 42, height: 42) + .foregroundColor(moodColor) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(Random.weekdayName(fromDate: entry.forDate!)) + .font(.headline) .foregroundColor(textColor) - Text(" - ") - .padding([.leading, .trailing], -10) + Text("-") + .foregroundColor(textColor.opacity(0.6)) + Text(Random.dayFormat(fromDate: entry.forDate!)) + .font(.headline) .foregroundColor(textColor) - Text(Random.dayFormat(fromDate:entry.forDate!)) - .font(.title3) - .foregroundColor(textColor) - Spacer() } - .multilineTextAlignment(.leading) - - Text(entry.moodValue == Mood.missing.rawValue ? String(localized: "mood_value_missing_tap_to_add") : "\(entry.moodString)") - .font(.body) - .foregroundColor(Color(UIColor.systemGray)) - .frame(maxWidth: .infinity, alignment: .leading) + + Text(entry.moodValue == Mood.missing.rawValue ? String(localized: "mood_value_missing_tap_to_add") : "\(entry.moodString)") + .font(.subheadline) + .foregroundColor(textColor.opacity(0.7)) } + + Spacer() } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(moodColor.opacity(0.12)) + ) } } struct EntryListView_Previews: PreviewProvider { static let fakeData = PersistenceController.shared.randomEntries(count: 1).first! - + static var previews: some View { - EntryListView(entry: EntryListView_Previews.fakeData) + VStack(spacing: 8) { + EntryListView(entry: EntryListView_Previews.fakeData) + EntryListView(entry: EntryListView_Previews.fakeData) + } + .padding() } }