diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 0db4cc0..8f1057f 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -16,7 +16,9 @@ 1C683FCA2792281400745862 /* Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C683FC92792281400745862 /* Stats.swift */; }; 1C683FCB2792281400745862 /* Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C683FC92792281400745862 /* Stats.swift */; }; 1C683FCC2792281400745862 /* Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C683FC92792281400745862 /* Stats.swift */; }; + 1C6B377A2799B78A001EF820 /* BGView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6B37792799B78A001EF820 /* BGView.swift */; }; 1C744F2C278CE15600953A57 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C744F2B278CE15600953A57 /* AppDelegate.swift */; }; + 1CA037702799FFA600D26164 /* ContentModeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA0376F2799FFA600D26164 /* ContentModeViewModel.swift */; }; 1CA2662D2793908700C0E12C /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90AEF278C7DDF001C4FEA /* Persistence.swift */; }; 1CC469AA278F30A0003E0C6E /* BGTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC469A9278F30A0003E0C6E /* BGTask.swift */; }; 1CC469AC27907D48003E0C6E /* DayChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC469AB27907D48003E0C6E /* DayChartView.swift */; }; @@ -108,7 +110,9 @@ 1C26190627960DC900FDC148 /* ChartViewItemBuildable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartViewItemBuildable.swift; sourceTree = ""; }; 1C412081278F2B8800D9153A /* FilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = ""; }; 1C683FC92792281400745862 /* Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stats.swift; sourceTree = ""; }; + 1C6B37792799B78A001EF820 /* BGView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGView.swift; sourceTree = ""; }; 1C744F2B278CE15600953A57 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 1CA0376F2799FFA600D26164 /* ContentModeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentModeViewModel.swift; sourceTree = ""; }; 1CC469A9278F30A0003E0C6E /* BGTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGTask.swift; sourceTree = ""; }; 1CC469AB27907D48003E0C6E /* DayChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayChartView.swift; sourceTree = ""; }; 1CD90AEC278C7DDF001C4FEA /* Shared.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Shared.xcdatamodel; sourceTree = ""; }; @@ -285,6 +289,7 @@ 1CD90B33278C7E38001C4FEA /* GraphView.swift */, 1CD90B36278C7E38001C4FEA /* HeaderStatsView.swift */, 1CD90B32278C7E38001C4FEA /* SettingsView.swift */, + 1C6B37792799B78A001EF820 /* BGView.swift */, ); path = views; sourceTree = ""; @@ -313,6 +318,7 @@ 1CD90B60278C7EBA001C4FEA /* Models */ = { isa = PBXGroup; children = ( + 1CA0376F2799FFA600D26164 /* ContentModeViewModel.swift */, 1CC469AB27907D48003E0C6E /* DayChartView.swift */, 1CD90B61278C7EBA001C4FEA /* Mood.swift */, 1CD90B62278C7EBA001C4FEA /* MoodEntryExtension.swift */, @@ -516,11 +522,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1CA037702799FFA600D26164 /* ContentModeViewModel.swift in Sources */, 1CD90B39278C7E38001C4FEA /* GraphView.swift in Sources */, 1C683FCA2792281400745862 /* Stats.swift in Sources */, 1CD90B76278C8119001C4FEA /* LocalNotification.swift in Sources */, 1CD90B16278C7DE0001C4FEA /* Feels.xcdatamodeld in Sources */, 1CC469AA278F30A0003E0C6E /* BGTask.swift in Sources */, + 1C6B377A2799B78A001EF820 /* BGView.swift in Sources */, 1C26190727960DC900FDC148 /* ChartViewItemBuildable.swift in Sources */, 1CD90B5D278C7EAD001C4FEA /* Random.swift in Sources */, 1C2618FE27960A4F00FDC148 /* FilterViewModel.swift in Sources */, diff --git a/Shared/Models/ContentModeViewModel.swift b/Shared/Models/ContentModeViewModel.swift new file mode 100644 index 0000000..f3a652f --- /dev/null +++ b/Shared/Models/ContentModeViewModel.swift @@ -0,0 +1,78 @@ +// +// ContentModeViewModel.swift +// Feels (iOS) +// +// Created by Trey Tartt on 1/20/22. +// + +import SwiftUI +import CoreData + +class ContentModeViewModel: ObservableObject { + @Published var grouped = [Int: [Int: [MoodEntry]]]() + + init() { + updateData() + } + + private func getGroupedData() { + grouped = PersistenceController.shared.splitIntoYearMonth() + } + + public func shouldShowTodayInput() -> Bool { + let fetchRequest = NSFetchRequest(entityName: "MoodEntry") + + var calendar = Calendar.current + calendar.timeZone = NSTimeZone.local + + // Get today's beginning & end + let dateFrom = calendar.startOfDay(for: Date()) // eg. 2016-10-10 00:00:00 + let dateTo = calendar.date(byAdding: .day, value: 1, to: dateFrom)! + // Note: Times are printed in UTC. Depending on where you live it won't print 00:00:00 but it will work with UTC times which can be converted to local time + + // Set predicate as date being today's date + let fromPredicate = NSPredicate(format: "%@ <= %K", dateFrom as NSDate, #keyPath(MoodEntry.forDate)) + let toPredicate = NSPredicate(format: "%K < %@", #keyPath(MoodEntry.forDate), dateTo as NSDate) + let datePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fromPredicate, toPredicate]) + fetchRequest.predicate = datePredicate + let entries = try! PersistenceController.shared.viewContext.count(for: fetchRequest) + + return entries == 0 + } + + public func updateData() { + getGroupedData() + } + + public func add(mood: Mood, forDate date: Date) { + PersistenceController.shared.add(mood: mood, forDate: Date()) + getGroupedData() + } + + public func delete(offsets: IndexSet, inMonth month: Int, inYear year: Int) { + if let monthEntries = grouped[year], + let entries = monthEntries[month] { + var mutableEntries = entries.sorted(by: { + $0.forDate! > $1.forDate! + }) + var entriesToDelete = [MoodEntry]() + for idx in offsets { + let obj = mutableEntries.remove(at: idx) + entriesToDelete.append(obj) + } + entriesToDelete.forEach({ entry in + PersistenceController.shared.viewContext.delete(entry) + }) + } + + do { + try PersistenceController.shared.viewContext.save() + getGroupedData() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } +} diff --git a/Shared/Persistence.swift b/Shared/Persistence.swift index a3d4733..29cf56a 100644 --- a/Shared/Persistence.swift +++ b/Shared/Persistence.swift @@ -84,7 +84,7 @@ class PersistenceController { } func populateTestData() { - for idx in 1..<25 { + for idx in 1..<120 { let newItem = MoodEntry(context: viewContext) newItem.timestamp = Date() newItem.moodValue = Int16(Mood.allValues.randomElement()!.rawValue) @@ -198,6 +198,49 @@ class PersistenceController { } }) } + + + public func splitIntoYearMonth() -> [Int: [Int: [MoodEntry]]] { + let data = PersistenceController.shared.getData(startDate: Date(timeIntervalSince1970: 0), + endDate: Date(), + includedDays: [1,2,3,4,5,6,7]).sorted(by: { + $0.forDate! < $1.forDate! + }) + var returnData = [Int: [Int: [MoodEntry]]]() + + if let earliestEntry = data.first, + let lastEntry = data.last { + + let calendar = Calendar.current + let components = calendar.dateComponents([.year], from: earliestEntry.forDate!) + let earliestYear = components.year! + + let latestComponents = calendar.dateComponents([.year], from: lastEntry.forDate!) + let latestYear = latestComponents.year! + + for year in earliestYear...latestYear { + var allMonths = [Int: [MoodEntry]]() + + for month in (1...12) { + var components = DateComponents() + components.month = month + components.year = year + let startDateOfMonth = Calendar.current.date(from: components)! + + let items = data.filter({ entry in + let components = calendar.dateComponents([.month, .year], from: startDateOfMonth) + let entryComponents = calendar.dateComponents([.month, .year], from: entry.forDate!) + return (components.month == entryComponents.month && components.year == entryComponents.year) + }) + if !items.isEmpty { + allMonths[month] = items + } + } + returnData[year] = allMonths + } + } + return returnData + } } extension NSManagedObjectContext { diff --git a/Shared/views/AddMoodHeaderView.swift b/Shared/views/AddMoodHeaderView.swift index c8e9722..20c0ee8 100644 --- a/Shared/views/AddMoodHeaderView.swift +++ b/Shared/views/AddMoodHeaderView.swift @@ -12,10 +12,11 @@ import CoreData struct AddMoodHeaderView: View { @Environment(\.managedObjectContext) private var viewContext - + let addItemClosure: ((Mood, Date) -> Void) + var body: some View { ZStack { - Color(UIColor.secondarySystemBackground) + Color(UIColor.systemBackground) VStack { Text("How is today?") @@ -48,22 +49,28 @@ struct AddMoodHeaderView: View { } private func addItem(withMood mood: Mood) { - withAnimation { - PersistenceController.shared.add(mood: mood, forDate: Date()) - } + addItemClosure(mood, Date()) } } struct AddMoodHeaderView_Previews: PreviewProvider { static var previews: some View { Group { - AddMoodHeaderView().environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) + AddMoodHeaderView(addItemClosure: { (_,_) in + + }).environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) - AddMoodHeaderView().preferredColorScheme(.dark).environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) + AddMoodHeaderView(addItemClosure: { (_,_) in + + }).preferredColorScheme(.dark).environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) - AddMoodHeaderView().environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) + AddMoodHeaderView(addItemClosure: { (_,_) in + + }).environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) - AddMoodHeaderView().preferredColorScheme(.dark).environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) + AddMoodHeaderView(addItemClosure: { (_,_) in + + }).preferredColorScheme(.dark).environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) } } } diff --git a/Shared/views/BGView.swift b/Shared/views/BGView.swift new file mode 100644 index 0000000..cc66668 --- /dev/null +++ b/Shared/views/BGView.swift @@ -0,0 +1,77 @@ +// +// IconView.swift +// Feels (iOS) +// +// Created by Trey Tartt on 1/20/22. +// + +import SwiftUI + +struct BGViewItem: View { + let mood: Mood + let size: CGSize + var color: Color + let animate: Bool + let yRowPosition: Float + + init(mood: Mood, size: CGSize, animate: Bool, yRowPosition: Float) { + color = mood.color + self.mood = mood + self.size = size + self.yRowPosition = yRowPosition + self.animate = animate + } + + var body: some View { + Mood.allValues.randomElement()?.icon + .resizable() + .frame(width: size.width, height: size.height) + .foregroundColor(color) +// .blur(radius: 3) + .opacity(0.1) + } +} + +struct BGView: View { + var numAcross: Int + var numDown: Int + let iconSize = 35 + + init() { + let screenWidth = UIScreen.main.bounds.width + numAcross = Int(screenWidth)/iconSize + + let screenHeight = UIScreen.main.bounds.height + numDown = Int(screenHeight)/iconSize + } + + var body: some View { + VStack { + ForEach(0...numDown, id: \.self) { row in + HStack { + ForEach(0...numAcross, id: \.self) { _ in + BGViewItem(mood: Mood.allValues.randomElement()!, + size: .init(width: iconSize,height: iconSize), + animate: false, + yRowPosition: Float(row)/Float(numDown)) + }.frame(minWidth: 0, maxWidth: .infinity) + } + .padding(.top, -8) + } + } + .padding(.top, -50) + } +} + +struct BGView_Previews: PreviewProvider { + static var previews: some View { + BGView().environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) + .onAppear(perform: { + PersistenceController.shared.populateMemory() + }) + + BGView() + .preferredColorScheme(.dark) + .environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) + } +} diff --git a/Shared/views/ContentView.swift b/Shared/views/ContentView.swift index 39ec6ac..e30287a 100644 --- a/Shared/views/ContentView.swift +++ b/Shared/views/ContentView.swift @@ -15,12 +15,11 @@ struct ContentView: View { @State private var showingSheet = false @State private var showTodayInput = true - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \MoodEntry.forDate, ascending: false)], - animation: .spring()) - private var items: FetchedResults - - init(){ } + @ObservedObject var viewModel = ContentModeViewModel() + + init(){ + UITabBar.appearance().backgroundColor = UIColor.systemBackground + } var body: some View { TabView { @@ -38,7 +37,6 @@ struct ContentView: View { .tabItem { Label("Stats", systemImage: "chart.line.uptrend.xyaxis") } - } } @@ -49,10 +47,14 @@ struct ContentView: View { showingSheet.toggle() }, label: { Image(systemName: "gear") - .foregroundColor(Color(UIColor.systemGray)) + .foregroundColor(Color(UIColor.darkGray)) .font(.system(size: 20)) }).sheet(isPresented: $showingSheet) { - SettingsView() + SettingsView(editedDataClosure: { + withAnimation{ + viewModel.updateData() + } + }) }.padding(.trailing) } } @@ -61,98 +63,125 @@ struct ContentView: View { let weekday = Calendar.current.component(.weekday, from: date) let calendar = Calendar.current let dayIndex = ((weekday - 1) + (calendar.firstWeekday - 1)) % 7 - return calendar.shortWeekdaySymbols[dayIndex] + return calendar.weekdaySymbols[dayIndex] + } + + private func monthName(fromMonthInt: Int) -> String { + let monthName = DateFormatter().monthSymbols[fromMonthInt-1] + return monthName + } + + private func dayFormat(fromDate date: Date) -> String { + let components = Calendar.current.dateComponents([.day], from: date) + let day = components.day! + + let formatter = NumberFormatter() + formatter.numberStyle = .ordinal + return formatter.string(from: NSNumber(integerLiteral: day)) ?? "" } private var listView: some View { - List { - ForEach(items) { item in - HStack { - item.mood.icon - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40, alignment: .center) - .foregroundColor(item.mood.color) - VStack { - HStack { - Text(weekdayName(fromDate:item.forDate!)) - .font(.title3) - .foregroundColor(Color(UIColor.label)) - .frame(maxWidth: 40, alignment: .leading) - Text(" - ") - .padding([.leading, .trailing], -10) - Text(item.forDate ?? Date(), style: .date) - .font(.title3) - .foregroundColor(Color(UIColor.label)) - .frame(maxWidth: .infinity, alignment: .leading) - } - Text("\(item.moodString)") - .font(.body) - .foregroundColor(Color(UIColor.systemGray)) - .frame(maxWidth: .infinity, alignment: .leading) - } + VStack { + List { + // for reach year + 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 + }), id: \.key) { month, entries in + Section(header: + HStack{ + Text(monthName(fromMonthInt: month)) + .font(.title2) + .foregroundColor(Color(UIColor.label)) + Text(String(year)) + .font(.title2) + .foregroundColor(Color(UIColor.label)) + }) { + // for reach all entries + ForEach(entries.sorted(by: { + $0.forDate! > $1.forDate! + }), id: \.self) { entry in + entryListView(entry: entry) + }.onDelete(perform: { offsets in + withAnimation { + viewModel.delete(offsets: offsets, inMonth: month, inYear: year) + } + }) + } + } } } - .onDelete(perform: deleteItems) + .background(Color.clear.ignoresSafeArea()) + .onAppear { + // Set the default to clear + UITableView.appearance().backgroundColor = .clear + } + } + } + + private func entryListView(entry: MoodEntry) -> some View { + HStack { + entry.mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40, alignment: .center) + .foregroundColor(entry.mood.color) + + VStack { + HStack { + Text(weekdayName(fromDate:entry.forDate!)) + .font(.title3) + .foregroundColor(Color(UIColor.label)) + Text(" - ") + .padding([.leading, .trailing], -10) + Text(dayFormat(fromDate:entry.forDate!)) + .font(.title3) + .foregroundColor(Color(UIColor.label)) + + Spacer() + } + .multilineTextAlignment(.leading) + + Text("\(entry.moodString)") + .font(.body) + .foregroundColor(Color(UIColor.systemGray)) + .frame(maxWidth: .infinity, alignment: .leading) + } } } private var mainView: some View { - VStack{ - settingsButtonView - if shouldShowTodayInput() { - AddMoodHeaderView() - .frame(minHeight: 85, maxHeight: 180) - .frame(minWidth: 0, maxWidth: .infinity) - } else { - HeaderStatsView(fakeData: false, backDays: 30) - .frame(minHeight: 85, maxHeight: 180) - // should match backDays above - Text("Past \(30) days") - .font(.body) - .foregroundColor(Color(UIColor.systemGray)) - .frame(maxWidth: .infinity, alignment: .center) + ZStack { + BGView() + VStack{ + settingsButtonView + if viewModel.shouldShowTodayInput() { + AddMoodHeaderView(addItemClosure: { (mood, date) in + withAnimation { + viewModel.add(mood: mood, forDate: date) + } + }) + .frame(minHeight: 85, maxHeight: 180) + .frame(minWidth: 0, maxWidth: .infinity) + } else { + HeaderStatsView(fakeData: false, backDays: 30) + .frame(minHeight: 85, maxHeight: 180) + // should match backDays above + Text("Past \(30) days") + .font(.body) + .foregroundColor(Color(UIColor.systemGray)) + .frame(maxWidth: .infinity, alignment: .center) + } + listView + .padding(.bottom) } - listView + .padding(.top, 50) } } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - offsets.map { items[$0] }.forEach(viewContext.delete) - - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } - - private func shouldShowTodayInput() -> Bool { - let fetchRequest = NSFetchRequest(entityName: "MoodEntry") - - var calendar = Calendar.current - calendar.timeZone = NSTimeZone.local - - // Get today's beginning & end - let dateFrom = calendar.startOfDay(for: Date()) // eg. 2016-10-10 00:00:00 - let dateTo = calendar.date(byAdding: .day, value: 1, to: dateFrom)! - // Note: Times are printed in UTC. Depending on where you live it won't print 00:00:00 but it will work with UTC times which can be converted to local time - - // Set predicate as date being today's date - let fromPredicate = NSPredicate(format: "%@ <= %K", dateFrom as NSDate, #keyPath(MoodEntry.forDate)) - let toPredicate = NSPredicate(format: "%K < %@", #keyPath(MoodEntry.forDate), dateTo as NSDate) - let datePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fromPredicate, toPredicate]) - fetchRequest.predicate = datePredicate - let entries = try! self.viewContext.count(for: fetchRequest) - - return entries == 0 - } } private let itemFormatter: DateFormatter = { diff --git a/Shared/views/SettingsView.swift b/Shared/views/SettingsView.swift index 74605a0..c35c6c9 100644 --- a/Shared/views/SettingsView.swift +++ b/Shared/views/SettingsView.swift @@ -10,6 +10,8 @@ import SwiftUI struct SettingsView: View { @Environment(\.dismiss) var dismiss + let editedDataClosure: (() -> Void) + @AppStorage("notificationDate") private var notificationDate = Date() { didSet { if self.showReminder { @@ -94,6 +96,7 @@ struct SettingsView: View { Color(UIColor.systemBackground) Button(action: { PersistenceController.shared.populateTestData() + editedDataClosure() }, label: { Text("Add test data") }) @@ -108,6 +111,7 @@ struct SettingsView: View { Color(UIColor.systemBackground) Button(action: { PersistenceController.shared.clearDB() + editedDataClosure() }, label: { Text("Clear DB") }) @@ -195,9 +199,13 @@ struct SettingsView: View { struct SettingsView_Previews: PreviewProvider { static var previews: some View { - SettingsView() + SettingsView(editedDataClosure: { + + }) - SettingsView() + SettingsView(editedDataClosure: { + + }) .preferredColorScheme(.dark) } }