From 644616784abef5a93b07e95dfedafcc49df65e63 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sat, 10 Jun 2023 12:22:25 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=92=BE=20Implement=20(local)=20persis?= =?UTF-8?q?tence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Graduator/Graduator.xcodeproj/project.pbxproj | 14 ++++- Graduator/Graduator/{ => Data}/Stub.swift | 0 Graduator/Graduator/Data/UnitsStore.swift | 54 +++++++++++++++++++ Graduator/Graduator/GraduatorApp.swift | 7 +++ Graduator/Graduator/Model/Subject.swift | 2 +- Graduator/Graduator/Model/Unit.swift | 3 +- Graduator/Graduator/Model/UnitsManager.swift | 27 ++++++++++ .../View/Cells/SubjectViewCell.swift | 15 ++++-- Graduator/Graduator/View/Views/UnitView.swift | 27 +++++++--- Graduator/Graduator/ViewModel/UnitVM.swift | 2 +- .../Graduator/ViewModel/UnitsManagerVM.swift | 31 +++++++++-- 11 files changed, 163 insertions(+), 19 deletions(-) rename Graduator/Graduator/{ => Data}/Stub.swift (100%) create mode 100644 Graduator/Graduator/Data/UnitsStore.swift diff --git a/Graduator/Graduator.xcodeproj/project.pbxproj b/Graduator/Graduator.xcodeproj/project.pbxproj index 3a41674..8c3656d 100644 --- a/Graduator/Graduator.xcodeproj/project.pbxproj +++ b/Graduator/Graduator.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ EC242B882A1FC605006FE760 /* NoGradesInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B872A1FC605006FE760 /* NoGradesInfo.swift */; }; EC242B8A2A1FCECA006FE760 /* AverageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B892A1FCECA006FE760 /* AverageBlockView.swift */; }; EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5FE5A42A20882F0028AA5F /* Formatters.swift */; }; + EC8BAD162A3465230062226B /* UnitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8BAD152A3465230062226B /* UnitsStore.swift */; }; ECB2FFCE2A23C4A700FF9F91 /* SubjectFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */; }; ECB2FFD02A23C4B700FF9F91 /* SubjectFormVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */; }; ECC581D22A1D085B006C55EF /* GraduatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC581D12A1D085B006C55EF /* GraduatorApp.swift */; }; @@ -41,6 +42,7 @@ EC242B872A1FC605006FE760 /* NoGradesInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoGradesInfo.swift; sourceTree = ""; }; EC242B892A1FCECA006FE760 /* AverageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AverageBlockView.swift; sourceTree = ""; }; EC5FE5A42A20882F0028AA5F /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = ""; }; + EC8BAD152A3465230062226B /* UnitsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsStore.swift; sourceTree = ""; }; ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubjectFormView.swift; sourceTree = ""; }; ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubjectFormVM.swift; sourceTree = ""; }; ECC581CE2A1D085B006C55EF /* Graduator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Graduator.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -108,6 +110,15 @@ path = Utils; sourceTree = ""; }; + EC8BAD142A34650D0062226B /* Data */ = { + isa = PBXGroup; + children = ( + EC242B822A1FAA9B006FE760 /* Stub.swift */, + EC8BAD152A3465230062226B /* UnitsStore.swift */, + ); + path = Data; + sourceTree = ""; + }; ECB2FFCC2A23C49500FF9F91 /* Forms */ = { isa = PBXGroup; children = ( @@ -135,12 +146,12 @@ ECC581D02A1D085B006C55EF /* Graduator */ = { isa = PBXGroup; children = ( + EC8BAD142A34650D0062226B /* Data */, ECE6E3C02A1F80F6004FE471 /* Model */, ECC581DF2A1D08C3006C55EF /* ViewModel */, EC242B6F2A1F8260006FE760 /* View */, ECC581D12A1D085B006C55EF /* GraduatorApp.swift */, EC242B702A1F8283006FE760 /* MainView.swift */, - EC242B822A1FAA9B006FE760 /* Stub.swift */, ECC581D52A1D085C006C55EF /* Assets.xcassets */, ECC581D72A1D085C006C55EF /* Preview Content */, ); @@ -251,6 +262,7 @@ EC242B6E2A1F81CC006FE760 /* UnitsManager.swift in Sources */, ECC581E52A1D0C44006C55EF /* UnitVM.swift in Sources */, EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */, + EC8BAD162A3465230062226B /* UnitsStore.swift in Sources */, EC242B7F2A1F83BF006FE760 /* UnitsManagerVM.swift in Sources */, EC242B7B2A1F838C006FE760 /* UnitViewCell.swift in Sources */, EC242B6C2A1F81AE006FE760 /* Subject.swift in Sources */, diff --git a/Graduator/Graduator/Stub.swift b/Graduator/Graduator/Data/Stub.swift similarity index 100% rename from Graduator/Graduator/Stub.swift rename to Graduator/Graduator/Data/Stub.swift diff --git a/Graduator/Graduator/Data/UnitsStore.swift b/Graduator/Graduator/Data/UnitsStore.swift new file mode 100644 index 0000000..947cb33 --- /dev/null +++ b/Graduator/Graduator/Data/UnitsStore.swift @@ -0,0 +1,54 @@ +// +// UnitsStore.swift +// Graduator +// +// Created by etudiant on 2023-06-10. +// + +import SwiftUI + + +class UnitsStore: ObservableObject { + + var units: [Unit] = [] + + private static func fileURL() throws -> URL { + try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ).appendingPathComponent("units.data") + } + + func load() async throws -> [Unit] { + + let task = Task<[Unit], Error> { + let fileURL = try Self.fileURL() + let data = try? Data(contentsOf: fileURL) + var units: [Unit] = [] + if (data == nil || data!.isEmpty) { + units = Stub.units + } + else if (data != nil) { + units = try JSONDecoder().decode([Unit].self, from: data!) + } + return units + } + + self.units = try await task.value + return self.units + } + + func save(units: [Unit]) async throws { + + let task = Task { + let data = try JSONEncoder().encode(units) + let outfile = try Self.fileURL() + try data.write(to: outfile) + } + + _ = try await task.value + } + +} diff --git a/Graduator/Graduator/GraduatorApp.swift b/Graduator/Graduator/GraduatorApp.swift index b35b8dd..54ba97c 100644 --- a/Graduator/Graduator/GraduatorApp.swift +++ b/Graduator/Graduator/GraduatorApp.swift @@ -14,6 +14,13 @@ struct GraduatorApp: App { var body: some Scene { WindowGroup { MainView(unitsManagerVM: unitsManagerVM) + .task { + do { + try await unitsManagerVM.load() + } catch { + fatalError(error.localizedDescription) + } + } .environmentObject(unitsManagerVM) } } diff --git a/Graduator/Graduator/Model/Subject.swift b/Graduator/Graduator/Model/Subject.swift index 4530ca7..e80cdae 100644 --- a/Graduator/Graduator/Model/Subject.swift +++ b/Graduator/Graduator/Model/Subject.swift @@ -7,7 +7,7 @@ import Foundation -struct Subject : Identifiable { +struct Subject : Identifiable, Codable { let id: UUID var name: String var weight: Int diff --git a/Graduator/Graduator/Model/Unit.swift b/Graduator/Graduator/Model/Unit.swift index 2695214..1a1b3d3 100644 --- a/Graduator/Graduator/Model/Unit.swift +++ b/Graduator/Graduator/Model/Unit.swift @@ -7,7 +7,7 @@ import Foundation -struct Unit : Identifiable { +struct Unit : Identifiable, Codable { let id: UUID var name: String var weight: Int @@ -15,6 +15,7 @@ struct Unit : Identifiable { var code: Int var subjects: [Subject] + // FIXME DRY func getAverage() -> Double? { var totalWeight = 0 var weightedSum = 0.0 diff --git a/Graduator/Graduator/Model/UnitsManager.swift b/Graduator/Graduator/Model/UnitsManager.swift index 9e9766e..f075abb 100644 --- a/Graduator/Graduator/Model/UnitsManager.swift +++ b/Graduator/Graduator/Model/UnitsManager.swift @@ -10,6 +10,32 @@ import Foundation struct UnitsManager { var units: [Unit] + private var store = UnitsStore() + + public init(units: [Unit] = [], store: UnitsStore = UnitsStore()) { + self.units = units + self.store = store + } + + mutating func load() async throws { + print("Loading...") + do { + self.units = try await store.load() + print("Units loaded") + } catch { + print("ERROR: Failed to load...") + } + } + + func save() async throws { + print("Saving...") + do { + try await store.save(units: units) + print("Units saved") + } catch { + print("ERROR: Failed to save...") + } + } func getTotalAverage() -> Double? { return getAverage(units: units) @@ -19,6 +45,7 @@ struct UnitsManager { return getAverage(units: units.filter { $0.isProfessional }) } + // FIXME DRY func getAverage(units: [Unit]) -> Double? { var totalWeight = 0 var weightedSum = 0.0 diff --git a/Graduator/Graduator/View/Cells/SubjectViewCell.swift b/Graduator/Graduator/View/Cells/SubjectViewCell.swift index 99cfb74..dc6f7f9 100644 --- a/Graduator/Graduator/View/Cells/SubjectViewCell.swift +++ b/Graduator/Graduator/View/Cells/SubjectViewCell.swift @@ -17,6 +17,9 @@ struct SubjectViewCell: View { var body: some View { HStack { VStack { + // FIXME (later, maybe) when getting updated from the UnitView, these two changes are persisted right away instead of waiting for user + // to confirm changes. Or is it rather that we were updating these fields, and then changing the grade, thus calling + // unitsManagerVM.updateUnit(unitVM) ? If so, how to even fix that? HStack { TextField("", text: $subjectVM.model.name) .disabled(!unitsManagerVM.isAllEditable) @@ -33,9 +36,15 @@ struct SubjectViewCell: View { .frame(width: 40) .onChange(of: isGradeEditable) { value in if !value { - subjectVM.onEdited() - unitVM.updateSubject(subjectVM) - unitsManagerVM.updateUnit(unitVM) + Task { + do { + subjectVM.onEdited() + unitVM.updateSubject(subjectVM) + try await unitsManagerVM.updateUnit(unitVM) + } catch { + print("Failed to update grade: \(error)") + } + } } } Image(systemName: isGradeEditable ? "checkmark" : "lock.open") diff --git a/Graduator/Graduator/View/Views/UnitView.swift b/Graduator/Graduator/View/Views/UnitView.swift index 1605b8a..01a13d0 100644 --- a/Graduator/Graduator/View/Views/UnitView.swift +++ b/Graduator/Graduator/View/Views/UnitView.swift @@ -19,7 +19,6 @@ struct UnitView: View { guard let index = offsets.first else { return } let subjectVMToDelete = unitVM.SubjectsVM[index] unitVM.deleteSubject(subjectVMToDelete) - unitsManagerVM.updateUnit(unitVM) } var body: some View { @@ -83,10 +82,16 @@ struct UnitView: View { Text("Annuler") } Button(action: { - unitsManagerVM.isAllEditable.toggle() - unitVM.onEdited() - unitVM.updateAllSubjects() - unitsManagerVM.updateUnit(unitVM) + Task { + do { + unitsManagerVM.isAllEditable.toggle() + unitVM.onEdited() + unitVM.updateAllSubjects() + try await unitsManagerVM.updateUnit(unitVM) + } catch { + print("Failed to update unit: \(error)") + } + } }) { Text("OK") } @@ -107,9 +112,15 @@ struct UnitView: View { leading: Button("Annuler") { showingForm = false }, trailing: Button("Enregistrer") { if let newSubject = formVM.createSubject() { - unitVM.addSubject(newSubject) - unitsManagerVM.updateUnit(unitVM) - showingForm = false + Task { + do { + unitVM.addSubject(newSubject) + try await unitsManagerVM.updateUnit(unitVM) + showingForm = false + } catch { + print("Failed to create unit: \(error)") + } + } } else { showAlert = true } diff --git a/Graduator/Graduator/ViewModel/UnitVM.swift b/Graduator/Graduator/ViewModel/UnitVM.swift index 703b2fc..3266ba1 100644 --- a/Graduator/Graduator/ViewModel/UnitVM.swift +++ b/Graduator/Graduator/ViewModel/UnitVM.swift @@ -60,7 +60,7 @@ class UnitVM : ObservableObject, Identifiable { init(unit: Unit) { original = unit model = original.data - subjectsVM = unit.subjects.map { SubjectVM(subject: $0) } + subjectsVM = original.subjects.map { SubjectVM(subject: $0) } } convenience init() { diff --git a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift index dc496ce..27e3ab3 100644 --- a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift +++ b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift @@ -46,7 +46,7 @@ class UnitsManagerVM : ObservableObject { @Published var model: UnitsManager.Data @Published var isEdited: Bool = false @Published var isAllEditable: Bool = false - + private var unitsVM: [UnitVM] public var UnitsVM: [UnitVM] { unitsVM } @@ -54,18 +54,41 @@ class UnitsManagerVM : ObservableObject { init(unitsManager: UnitsManager) { original = unitsManager model = original.data - unitsVM = unitsManager.units.map { UnitVM(unit: $0) } + unitsVM = original.units.map { UnitVM(unit: $0) } } convenience init() { self.init(unitsManager: UnitsManager(units: [])) } - func updateUnit(_ unitVM: UnitVM) { + func load() async throws { + print("Loading VM...") + do { + try await original.load() + DispatchQueue.main.async { + self.model = self.original.data + self.unitsVM = self.original.units.map { UnitVM(unit: $0) } + print("VM loaded") + } + } catch { + print("ERROR: Failed to load VM...") + } + } + + func updateUnit(_ unitVM: UnitVM) async throws { guard let index = unitsVM.firstIndex(where: { $0.id == unitVM.id }) else { return } let updatedUnit = unitsVM[index].model original.units[index].update(from: updatedUnit) - model = original.data + print("Saving VM...") + do { + try await original.save() + print("VM saved") + DispatchQueue.main.async { + self.model = self.original.data + } + } catch { + print("ERROR: Failed to save VM...") + } } var TotalAverage: Double? { -- 2.36.3 From 54fc430dce7dd08a723aff460c1a4df4712498d0 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sat, 10 Jun 2023 16:29:52 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=94=87=20Remove=20some=20print=20stat?= =?UTF-8?q?ements,=20improve=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Graduator/Graduator/Model/UnitsManager.swift | 6 ++---- Graduator/Graduator/View/Cells/SubjectViewCell.swift | 7 ++++--- Graduator/Graduator/View/Views/UnitView.swift | 6 ++++-- Graduator/Graduator/ViewModel/UnitsManagerVM.swift | 6 ++---- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Graduator/Graduator/Model/UnitsManager.swift b/Graduator/Graduator/Model/UnitsManager.swift index f075abb..fac8c28 100644 --- a/Graduator/Graduator/Model/UnitsManager.swift +++ b/Graduator/Graduator/Model/UnitsManager.swift @@ -18,21 +18,19 @@ struct UnitsManager { } mutating func load() async throws { - print("Loading...") do { self.units = try await store.load() - print("Units loaded") } catch { + // DEV: this should be replaced with proper error handling before ever going to prod print("ERROR: Failed to load...") } } func save() async throws { - print("Saving...") do { try await store.save(units: units) - print("Units saved") } catch { + // DEV: this should be replaced with proper error handling before ever going to prod print("ERROR: Failed to save...") } } diff --git a/Graduator/Graduator/View/Cells/SubjectViewCell.swift b/Graduator/Graduator/View/Cells/SubjectViewCell.swift index dc6f7f9..54fc022 100644 --- a/Graduator/Graduator/View/Cells/SubjectViewCell.swift +++ b/Graduator/Graduator/View/Cells/SubjectViewCell.swift @@ -18,8 +18,8 @@ struct SubjectViewCell: View { HStack { VStack { // FIXME (later, maybe) when getting updated from the UnitView, these two changes are persisted right away instead of waiting for user - // to confirm changes. Or is it rather that we were updating these fields, and then changing the grade, thus calling - // unitsManagerVM.updateUnit(unitVM) ? If so, how to even fix that? + // to confirm changes. They just don't get brought into the main UnitsManager, so the average is not recalculated when a unit's weight changes + // when 'cancel' is hit, it would be nice to reset these two values as well, like a user might expect HStack { TextField("", text: $subjectVM.model.name) .disabled(!unitsManagerVM.isAllEditable) @@ -42,7 +42,8 @@ struct SubjectViewCell: View { unitVM.updateSubject(subjectVM) try await unitsManagerVM.updateUnit(unitVM) } catch { - print("Failed to update grade: \(error)") + // DEV: this should be replaced with proper error handling before ever going to prod + print("ERROR: Failed to update grade: \(error)") } } } diff --git a/Graduator/Graduator/View/Views/UnitView.swift b/Graduator/Graduator/View/Views/UnitView.swift index 01a13d0..3a4650a 100644 --- a/Graduator/Graduator/View/Views/UnitView.swift +++ b/Graduator/Graduator/View/Views/UnitView.swift @@ -89,7 +89,8 @@ struct UnitView: View { unitVM.updateAllSubjects() try await unitsManagerVM.updateUnit(unitVM) } catch { - print("Failed to update unit: \(error)") + // DEV: this should be replaced with proper error handling before ever going to prod + print("ERROR: Failed to update unit: \(error)") } } }) { @@ -118,7 +119,8 @@ struct UnitView: View { try await unitsManagerVM.updateUnit(unitVM) showingForm = false } catch { - print("Failed to create unit: \(error)") + // DEV: this should be replaced with proper error handling before ever going to prod + print("ERROR: Failed to create unit: \(error)") } } } else { diff --git a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift index 27e3ab3..296a6d4 100644 --- a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift +++ b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift @@ -62,15 +62,14 @@ class UnitsManagerVM : ObservableObject { } func load() async throws { - print("Loading VM...") do { try await original.load() DispatchQueue.main.async { self.model = self.original.data self.unitsVM = self.original.units.map { UnitVM(unit: $0) } - print("VM loaded") } } catch { + // DEV: this should be replaced with proper error handling before ever going to prod print("ERROR: Failed to load VM...") } } @@ -79,14 +78,13 @@ class UnitsManagerVM : ObservableObject { guard let index = unitsVM.firstIndex(where: { $0.id == unitVM.id }) else { return } let updatedUnit = unitsVM[index].model original.units[index].update(from: updatedUnit) - print("Saving VM...") do { try await original.save() - print("VM saved") DispatchQueue.main.async { self.model = self.original.data } } catch { + // DEV: this should be replaced with proper error handling before ever going to prod print("ERROR: Failed to save VM...") } } -- 2.36.3 From 4c657bbfe10c01a8e6f3acc391f5b84f7ed8ad27 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sat, 10 Jun 2023 16:30:35 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Implement=20DRY=20on?= =?UTF-8?q?=20weighted=20average=20calculation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Graduator/Graduator.xcodeproj/project.pbxproj | 8 ++++++ Graduator/Graduator/Model/Subject.swift | 2 +- Graduator/Graduator/Model/Unit.swift | 18 +++---------- Graduator/Graduator/Model/UnitsManager.swift | 22 +++------------- .../Model/WeightedAverageCalculator.swift | 26 +++++++++++++++++++ Graduator/Graduator/Model/WeightedGrade.swift | 13 ++++++++++ 6 files changed, 54 insertions(+), 35 deletions(-) create mode 100644 Graduator/Graduator/Model/WeightedAverageCalculator.swift create mode 100644 Graduator/Graduator/Model/WeightedGrade.swift diff --git a/Graduator/Graduator.xcodeproj/project.pbxproj b/Graduator/Graduator.xcodeproj/project.pbxproj index 8c3656d..2041f39 100644 --- a/Graduator/Graduator.xcodeproj/project.pbxproj +++ b/Graduator/Graduator.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ EC242B8A2A1FCECA006FE760 /* AverageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B892A1FCECA006FE760 /* AverageBlockView.swift */; }; EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5FE5A42A20882F0028AA5F /* Formatters.swift */; }; EC8BAD162A3465230062226B /* UnitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8BAD152A3465230062226B /* UnitsStore.swift */; }; + EC8BAD1A2A34BC170062226B /* WeightedGrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8BAD192A34BC170062226B /* WeightedGrade.swift */; }; + EC8BAD1C2A34BE4C0062226B /* WeightedAverageCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8BAD1B2A34BE4C0062226B /* WeightedAverageCalculator.swift */; }; ECB2FFCE2A23C4A700FF9F91 /* SubjectFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */; }; ECB2FFD02A23C4B700FF9F91 /* SubjectFormVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */; }; ECC581D22A1D085B006C55EF /* GraduatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC581D12A1D085B006C55EF /* GraduatorApp.swift */; }; @@ -43,6 +45,8 @@ EC242B892A1FCECA006FE760 /* AverageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AverageBlockView.swift; sourceTree = ""; }; EC5FE5A42A20882F0028AA5F /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = ""; }; EC8BAD152A3465230062226B /* UnitsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsStore.swift; sourceTree = ""; }; + EC8BAD192A34BC170062226B /* WeightedGrade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightedGrade.swift; sourceTree = ""; }; + EC8BAD1B2A34BE4C0062226B /* WeightedAverageCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightedAverageCalculator.swift; sourceTree = ""; }; ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubjectFormView.swift; sourceTree = ""; }; ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubjectFormVM.swift; sourceTree = ""; }; ECC581CE2A1D085B006C55EF /* Graduator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Graduator.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -183,6 +187,8 @@ EC242B6B2A1F81AE006FE760 /* Subject.swift */, EC242B692A1F8189006FE760 /* Unit.swift */, EC242B6D2A1F81CC006FE760 /* UnitsManager.swift */, + EC8BAD192A34BC170062226B /* WeightedGrade.swift */, + EC8BAD1B2A34BE4C0062226B /* WeightedAverageCalculator.swift */, ); path = Model; sourceTree = ""; @@ -265,6 +271,8 @@ EC8BAD162A3465230062226B /* UnitsStore.swift in Sources */, EC242B7F2A1F83BF006FE760 /* UnitsManagerVM.swift in Sources */, EC242B7B2A1F838C006FE760 /* UnitViewCell.swift in Sources */, + EC8BAD1C2A34BE4C0062226B /* WeightedAverageCalculator.swift in Sources */, + EC8BAD1A2A34BC170062226B /* WeightedGrade.swift in Sources */, EC242B6C2A1F81AE006FE760 /* Subject.swift in Sources */, EC242B8A2A1FCECA006FE760 /* AverageBlockView.swift in Sources */, ECB2FFD02A23C4B700FF9F91 /* SubjectFormVM.swift in Sources */, diff --git a/Graduator/Graduator/Model/Subject.swift b/Graduator/Graduator/Model/Subject.swift index e80cdae..49d21eb 100644 --- a/Graduator/Graduator/Model/Subject.swift +++ b/Graduator/Graduator/Model/Subject.swift @@ -7,7 +7,7 @@ import Foundation -struct Subject : Identifiable, Codable { +struct Subject : Identifiable, Codable, WeightedGrade { let id: UUID var name: String var weight: Int diff --git a/Graduator/Graduator/Model/Unit.swift b/Graduator/Graduator/Model/Unit.swift index 1a1b3d3..445bf2c 100644 --- a/Graduator/Graduator/Model/Unit.swift +++ b/Graduator/Graduator/Model/Unit.swift @@ -7,28 +7,16 @@ import Foundation -struct Unit : Identifiable, Codable { +struct Unit : Identifiable, Codable, WeightedGrade { let id: UUID var name: String var weight: Int + var grade: Double? { getAverage() } var isProfessional: Bool var code: Int var subjects: [Subject] - // FIXME DRY func getAverage() -> Double? { - var totalWeight = 0 - var weightedSum = 0.0 - - for subject in subjects { - if let grade = subject.grade { - totalWeight += subject.weight - weightedSum += grade * Double(subject.weight) - } - } - - guard totalWeight > 0 else { return nil } - - return weightedSum / Double(totalWeight) + return WeightedAverageCalculator.average(elements: subjects) } } diff --git a/Graduator/Graduator/Model/UnitsManager.swift b/Graduator/Graduator/Model/UnitsManager.swift index fac8c28..fe2876f 100644 --- a/Graduator/Graduator/Model/UnitsManager.swift +++ b/Graduator/Graduator/Model/UnitsManager.swift @@ -36,27 +36,11 @@ struct UnitsManager { } func getTotalAverage() -> Double? { - return getAverage(units: units) + return WeightedAverageCalculator.average(elements: units) } func getProfessionalAverage() -> Double? { - return getAverage(units: units.filter { $0.isProfessional }) - } - - // FIXME DRY - func getAverage(units: [Unit]) -> Double? { - var totalWeight = 0 - var weightedSum = 0.0 - - for unit in units { - if let grade = unit.getAverage() { - totalWeight += unit.weight - weightedSum += grade * Double(unit.weight) - } - } - - guard totalWeight > 0 else { return nil } - - return weightedSum / Double(totalWeight) + let professionalUnits = units.filter { $0.isProfessional } + return WeightedAverageCalculator.average(elements: professionalUnits) } } diff --git a/Graduator/Graduator/Model/WeightedAverageCalculator.swift b/Graduator/Graduator/Model/WeightedAverageCalculator.swift new file mode 100644 index 0000000..7ec2a1f --- /dev/null +++ b/Graduator/Graduator/Model/WeightedAverageCalculator.swift @@ -0,0 +1,26 @@ +// +// WeightedAverageCalculator.swift +// Graduator +// +// Created by etudiant on 2023-06-10. +// + +import Foundation + +struct WeightedAverageCalculator { + static func average(elements: [T]) -> Double? { + var totalWeight = 0 + var weightedSum = 0.0 + + for element in elements { + if let grade = element.grade { + totalWeight += element.weight + weightedSum += grade * Double(element.weight) + } + } + + guard totalWeight > 0 else { return nil } + + return weightedSum / Double(totalWeight) + } +} diff --git a/Graduator/Graduator/Model/WeightedGrade.swift b/Graduator/Graduator/Model/WeightedGrade.swift new file mode 100644 index 0000000..8d805c3 --- /dev/null +++ b/Graduator/Graduator/Model/WeightedGrade.swift @@ -0,0 +1,13 @@ +// +// WeightedGrade.swift +// Graduator +// +// Created by etudiant on 2023-06-10. +// + +import Foundation + +protocol WeightedGrade { + var weight: Int { get } + var grade: Double? { get } +} -- 2.36.3 From 0858bd0a900653c0fbd4a276634a7b47119e62f0 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sat, 10 Jun 2023 16:40:21 +0200 Subject: [PATCH 4/7] :pencil2: :memo: Fix typos, add info --- Graduator/Graduator/Data/Stub.swift | 4 ++-- README.md | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Graduator/Graduator/Data/Stub.swift b/Graduator/Graduator/Data/Stub.swift index 4cd9121..31f23ea 100644 --- a/Graduator/Graduator/Data/Stub.swift +++ b/Graduator/Graduator/Data/Stub.swift @@ -121,7 +121,7 @@ struct Stub { ), Subject( id: UUID(), - name: "Architecture de projetc C# .NET (1)", + name: "Architecture de projet C# .NET (1)", weight: 5, grade: 14.5/20.0 ), @@ -148,7 +148,7 @@ struct Stub { subjects: [ Subject( id: UUID(), - name: "Architecture de projetc C# .NET (2)", + name: "Architecture de projet C# .NET (2)", weight: 4, grade: 12.17/20.0 ), diff --git a/README.md b/README.md index 6ef3b57..5e7ece5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ Graduator is an iOS application developed with SwiftUI that helps users manage t Beyond those basic features, some details need to be specified here. +### Persistence + +The data is set to be persisted, unless you're using XCode's previewer canvas. + +The local persistence solution has been tested manually on the iOS Simulator. + +Upon first launching the app, it is set up to load a stub. + ### Weighted average A weighted average means that a `subject` or `unit`'s weight plays a part in calculating the average. Users can observe that increasing the weight of a `subject`, for instance, will make the average of the parent `unit` tend more towards that `subject`'s grade. @@ -39,6 +47,7 @@ After a grade was changed, in order to save the change and to see it reflected i Finally, users can create a `subject` when in edit mode. After clicking on *'Modifier'*, look for a `+` in the top navigation bar. +subject deleted creating a subject subject created @@ -130,9 +139,9 @@ classDiagram It might be useful to note that, just like `UnitVM`s aggregate `SubjectVM`s, `Unit`s aggregate `Subject`s, but these relationship between `Model` entities were removed from the diagram above for clarity. -The same is true with the View-related classes. +The same is true with the `View`-related classes. -Here is the diagram with those relationships depicted. +Here is the diagram with those relationships depicted, and the local persistence solution added. @@ -146,6 +155,7 @@ classDiagram class UnitsManagerVM { -original: UnitsManager + +load() +model: UnitsManager.Data +isEdited: Bool +isAllEditable: Bool @@ -175,6 +185,9 @@ classDiagram class UnitsManager { + -store: UnitsStore + +save() + +load() +getTotalAverage(): Double? +getProfessionalAverage(): Double? +getAverage(units: Unit[]): Double? @@ -198,6 +211,11 @@ classDiagram +data: Data +update(from: Data) } + + class UnitsStore { + +load(defaultValue: T[]) + +save(elements: T[]) + } MainView --> "*" UnitView MainView --> UnitsManagerVM @@ -215,5 +233,8 @@ classDiagram SubjectVM --> Subject UnitsManager --> "*" Unit + UnitsManager --> UnitsStore + UnitsManager --> Stub + Stub --> "*" Unit Unit --> "*" Subject ``` -- 2.36.3 From ce61496b5b450bbe1c8504f2a87ccde32892d9a2 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sat, 10 Jun 2023 17:15:25 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=92=BB=20?= =?UTF-8?q?=F0=9F=94=A5=20Decouple=20Data/UnitsStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Graduator/Graduator.xcodeproj/project.pbxproj | 2 +- Graduator/Graduator/Data/UnitsStore.swift | 30 ++++++++----------- .../Graduator/{Data => Model}/Stub.swift | 0 Graduator/Graduator/Model/UnitsManager.swift | 4 +-- 4 files changed, 15 insertions(+), 21 deletions(-) rename Graduator/Graduator/{Data => Model}/Stub.swift (100%) diff --git a/Graduator/Graduator.xcodeproj/project.pbxproj b/Graduator/Graduator.xcodeproj/project.pbxproj index 2041f39..4ab0d6c 100644 --- a/Graduator/Graduator.xcodeproj/project.pbxproj +++ b/Graduator/Graduator.xcodeproj/project.pbxproj @@ -117,7 +117,6 @@ EC8BAD142A34650D0062226B /* Data */ = { isa = PBXGroup; children = ( - EC242B822A1FAA9B006FE760 /* Stub.swift */, EC8BAD152A3465230062226B /* UnitsStore.swift */, ); path = Data; @@ -184,6 +183,7 @@ ECE6E3C02A1F80F6004FE471 /* Model */ = { isa = PBXGroup; children = ( + EC242B822A1FAA9B006FE760 /* Stub.swift */, EC242B6B2A1F81AE006FE760 /* Subject.swift */, EC242B692A1F8189006FE760 /* Unit.swift */, EC242B6D2A1F81CC006FE760 /* UnitsManager.swift */, diff --git a/Graduator/Graduator/Data/UnitsStore.swift b/Graduator/Graduator/Data/UnitsStore.swift index 947cb33..045b439 100644 --- a/Graduator/Graduator/Data/UnitsStore.swift +++ b/Graduator/Graduator/Data/UnitsStore.swift @@ -9,41 +9,35 @@ import SwiftUI class UnitsStore: ObservableObject { - - var units: [Unit] = [] - + private static func fileURL() throws -> URL { try FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false - ).appendingPathComponent("units.data") + ).appendingPathComponent("my.data") } - func load() async throws -> [Unit] { - - let task = Task<[Unit], Error> { + func load(defaultValue: [T]) async throws -> [T] { + + let task = Task<[T], Error> { let fileURL = try Self.fileURL() let data = try? Data(contentsOf: fileURL) - var units: [Unit] = [] - if (data == nil || data!.isEmpty) { - units = Stub.units - } - else if (data != nil) { - units = try JSONDecoder().decode([Unit].self, from: data!) + var elements: [T] = defaultValue + if let validData = data, !validData.isEmpty { + elements = try JSONDecoder().decode([T].self, from: validData) } - return units + return elements } - self.units = try await task.value - return self.units + return try await task.value } - func save(units: [Unit]) async throws { + func save(elements: [T]) async throws { let task = Task { - let data = try JSONEncoder().encode(units) + let data = try JSONEncoder().encode(elements) let outfile = try Self.fileURL() try data.write(to: outfile) } diff --git a/Graduator/Graduator/Data/Stub.swift b/Graduator/Graduator/Model/Stub.swift similarity index 100% rename from Graduator/Graduator/Data/Stub.swift rename to Graduator/Graduator/Model/Stub.swift diff --git a/Graduator/Graduator/Model/UnitsManager.swift b/Graduator/Graduator/Model/UnitsManager.swift index fe2876f..a232324 100644 --- a/Graduator/Graduator/Model/UnitsManager.swift +++ b/Graduator/Graduator/Model/UnitsManager.swift @@ -19,7 +19,7 @@ struct UnitsManager { mutating func load() async throws { do { - self.units = try await store.load() + self.units = try await store.load(defaultValue: Stub.units) } catch { // DEV: this should be replaced with proper error handling before ever going to prod print("ERROR: Failed to load...") @@ -28,7 +28,7 @@ struct UnitsManager { func save() async throws { do { - try await store.save(units: units) + try await store.save(elements: units) } catch { // DEV: this should be replaced with proper error handling before ever going to prod print("ERROR: Failed to save...") -- 2.36.3 From 04c27dde38a03fd7f4b2b7c73ebd37669704d035 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sat, 10 Jun 2023 19:13:09 +0200 Subject: [PATCH 6/7] :bug: Fix #6 : Make cancelling make more sense --- Graduator/Graduator/Data/UnitsStore.swift | 2 +- Graduator/Graduator/View/Cells/SubjectViewCell.swift | 3 --- Graduator/Graduator/View/Views/UnitView.swift | 9 +++++++++ Graduator/Graduator/ViewModel/UnitsManagerVM.swift | 1 - 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Graduator/Graduator/Data/UnitsStore.swift b/Graduator/Graduator/Data/UnitsStore.swift index 045b439..a8ebfbe 100644 --- a/Graduator/Graduator/Data/UnitsStore.swift +++ b/Graduator/Graduator/Data/UnitsStore.swift @@ -16,7 +16,7 @@ class UnitsStore: ObservableObject { in: .userDomainMask, appropriateFor: nil, create: false - ).appendingPathComponent("my.data") + ).appendingPathComponent("my.local.data") } func load(defaultValue: [T]) async throws -> [T] { diff --git a/Graduator/Graduator/View/Cells/SubjectViewCell.swift b/Graduator/Graduator/View/Cells/SubjectViewCell.swift index 54fc022..89b3f6c 100644 --- a/Graduator/Graduator/View/Cells/SubjectViewCell.swift +++ b/Graduator/Graduator/View/Cells/SubjectViewCell.swift @@ -17,9 +17,6 @@ struct SubjectViewCell: View { var body: some View { HStack { VStack { - // FIXME (later, maybe) when getting updated from the UnitView, these two changes are persisted right away instead of waiting for user - // to confirm changes. They just don't get brought into the main UnitsManager, so the average is not recalculated when a unit's weight changes - // when 'cancel' is hit, it would be nice to reset these two values as well, like a user might expect HStack { TextField("", text: $subjectVM.model.name) .disabled(!unitsManagerVM.isAllEditable) diff --git a/Graduator/Graduator/View/Views/UnitView.swift b/Graduator/Graduator/View/Views/UnitView.swift index 3a4650a..93dc0c6 100644 --- a/Graduator/Graduator/View/Views/UnitView.swift +++ b/Graduator/Graduator/View/Views/UnitView.swift @@ -15,6 +15,8 @@ struct UnitView: View { @State private var showingForm: Bool = false var formVM = SubjectFormVM() + // TODO maybe later: when a subject is deleted this way, if the user clicks on 'Annuler', the deletion should be cancelled + // then update the readme accrodingly private func delete(at offsets: IndexSet) { guard let index = offsets.first else { return } let subjectVMToDelete = unitVM.SubjectsVM[index] @@ -75,9 +77,15 @@ struct UnitView: View { Button(action: { showingForm = true }) { Image(systemName: "plus") } + // FIXME this does cancel the changes made **to unit and subject** names and weights, + // but whereas names visibly revert instantaneously to old values, like we should expect, + // weights are displayed as new, cancelled values until user clicks on 'Modifier' again, when it finally takes on the true, old, value + // ... weights should visibly revert instantaneously to old values Button(action: { + unitVM.isEdited = false unitsManagerVM.isAllEditable.toggle() unitVM.onEdited(isCancelled: true) + unitVM.SubjectsVM.forEach { $0.onEdited(isCancelled: true) } }) { Text("Annuler") } @@ -137,6 +145,7 @@ struct UnitView: View { // If user navigates back while editing but before clicking 'OK', the changes are cancelled .onDisappear(perform: { if unitsManagerVM.isAllEditable { + unitVM.isEdited = false unitVM.onEdited(isCancelled: true) unitsManagerVM.isAllEditable = false } diff --git a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift index 296a6d4..5f44b52 100644 --- a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift +++ b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift @@ -44,7 +44,6 @@ class UnitsManagerVM : ObservableObject { private var original: UnitsManager @Published var model: UnitsManager.Data - @Published var isEdited: Bool = false @Published var isAllEditable: Bool = false private var unitsVM: [UnitVM] -- 2.36.3 From f02fe8e264bb0a8980f84a642c93f82ad331f603 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sat, 10 Jun 2023 20:19:30 +0200 Subject: [PATCH 7/7] :broom: Clean up comments --- Graduator/Graduator/Data/UnitsStore.swift | 2 +- Graduator/Graduator/View/Views/UnitView.swift | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/Graduator/Graduator/Data/UnitsStore.swift b/Graduator/Graduator/Data/UnitsStore.swift index a8ebfbe..9b1cfbc 100644 --- a/Graduator/Graduator/Data/UnitsStore.swift +++ b/Graduator/Graduator/Data/UnitsStore.swift @@ -16,7 +16,7 @@ class UnitsStore: ObservableObject { in: .userDomainMask, appropriateFor: nil, create: false - ).appendingPathComponent("my.local.data") + ).appendingPathComponent("dat.data.tho") } func load(defaultValue: [T]) async throws -> [T] { diff --git a/Graduator/Graduator/View/Views/UnitView.swift b/Graduator/Graduator/View/Views/UnitView.swift index 93dc0c6..37877bb 100644 --- a/Graduator/Graduator/View/Views/UnitView.swift +++ b/Graduator/Graduator/View/Views/UnitView.swift @@ -15,8 +15,6 @@ struct UnitView: View { @State private var showingForm: Bool = false var formVM = SubjectFormVM() - // TODO maybe later: when a subject is deleted this way, if the user clicks on 'Annuler', the deletion should be cancelled - // then update the readme accrodingly private func delete(at offsets: IndexSet) { guard let index = offsets.first else { return } let subjectVMToDelete = unitVM.SubjectsVM[index] @@ -77,10 +75,6 @@ struct UnitView: View { Button(action: { showingForm = true }) { Image(systemName: "plus") } - // FIXME this does cancel the changes made **to unit and subject** names and weights, - // but whereas names visibly revert instantaneously to old values, like we should expect, - // weights are displayed as new, cancelled values until user clicks on 'Modifier' again, when it finally takes on the true, old, value - // ... weights should visibly revert instantaneously to old values Button(action: { unitVM.isEdited = false unitsManagerVM.isAllEditable.toggle() -- 2.36.3