diff --git a/Graduator/Graduator.xcodeproj/project.pbxproj b/Graduator/Graduator.xcodeproj/project.pbxproj index 3a41674..4ab0d6c 100644 --- a/Graduator/Graduator.xcodeproj/project.pbxproj +++ b/Graduator/Graduator.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 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 */; }; + 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 */; }; @@ -41,6 +44,9 @@ 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 = ""; }; + 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; }; @@ -108,6 +114,14 @@ path = Utils; sourceTree = ""; }; + EC8BAD142A34650D0062226B /* Data */ = { + isa = PBXGroup; + children = ( + EC8BAD152A3465230062226B /* UnitsStore.swift */, + ); + path = Data; + sourceTree = ""; + }; ECB2FFCC2A23C49500FF9F91 /* Forms */ = { isa = PBXGroup; children = ( @@ -135,12 +149,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 */, ); @@ -169,9 +183,12 @@ ECE6E3C02A1F80F6004FE471 /* Model */ = { isa = PBXGroup; children = ( + EC242B822A1FAA9B006FE760 /* Stub.swift */, EC242B6B2A1F81AE006FE760 /* Subject.swift */, EC242B692A1F8189006FE760 /* Unit.swift */, EC242B6D2A1F81CC006FE760 /* UnitsManager.swift */, + EC8BAD192A34BC170062226B /* WeightedGrade.swift */, + EC8BAD1B2A34BE4C0062226B /* WeightedAverageCalculator.swift */, ); path = Model; sourceTree = ""; @@ -251,8 +268,11 @@ 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 */, + 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/Data/UnitsStore.swift b/Graduator/Graduator/Data/UnitsStore.swift new file mode 100644 index 0000000..9b1cfbc --- /dev/null +++ b/Graduator/Graduator/Data/UnitsStore.swift @@ -0,0 +1,48 @@ +// +// UnitsStore.swift +// Graduator +// +// Created by etudiant on 2023-06-10. +// + +import SwiftUI + + +class UnitsStore: ObservableObject { + + private static func fileURL() throws -> URL { + try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ).appendingPathComponent("dat.data.tho") + } + + func load(defaultValue: [T]) async throws -> [T] { + + let task = Task<[T], Error> { + let fileURL = try Self.fileURL() + let data = try? Data(contentsOf: fileURL) + var elements: [T] = defaultValue + if let validData = data, !validData.isEmpty { + elements = try JSONDecoder().decode([T].self, from: validData) + } + return elements + } + + return try await task.value + } + + func save(elements: [T]) async throws { + + let task = Task { + let data = try JSONEncoder().encode(elements) + 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/Stub.swift b/Graduator/Graduator/Model/Stub.swift similarity index 97% rename from Graduator/Graduator/Stub.swift rename to Graduator/Graduator/Model/Stub.swift index 4cd9121..31f23ea 100644 --- a/Graduator/Graduator/Stub.swift +++ b/Graduator/Graduator/Model/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/Graduator/Graduator/Model/Subject.swift b/Graduator/Graduator/Model/Subject.swift index 4530ca7..49d21eb 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, 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 2695214..445bf2c 100644 --- a/Graduator/Graduator/Model/Unit.swift +++ b/Graduator/Graduator/Model/Unit.swift @@ -7,27 +7,16 @@ import Foundation -struct Unit : Identifiable { +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] 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 9e9766e..a232324 100644 --- a/Graduator/Graduator/Model/UnitsManager.swift +++ b/Graduator/Graduator/Model/UnitsManager.swift @@ -10,28 +10,37 @@ import Foundation struct UnitsManager { var units: [Unit] - - func getTotalAverage() -> Double? { - return getAverage(units: units) + private var store = UnitsStore() + + public init(units: [Unit] = [], store: UnitsStore = UnitsStore()) { + self.units = units + self.store = store } - func getProfessionalAverage() -> Double? { - return getAverage(units: units.filter { $0.isProfessional }) + mutating func load() async throws { + do { + 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...") + } } - 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) - } + func save() async throws { + do { + 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...") } - - guard totalWeight > 0 else { return nil } - - return weightedSum / Double(totalWeight) + } + + func getTotalAverage() -> Double? { + return WeightedAverageCalculator.average(elements: units) + } + + func getProfessionalAverage() -> Double? { + 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 } +} diff --git a/Graduator/Graduator/View/Cells/SubjectViewCell.swift b/Graduator/Graduator/View/Cells/SubjectViewCell.swift index 99cfb74..89b3f6c 100644 --- a/Graduator/Graduator/View/Cells/SubjectViewCell.swift +++ b/Graduator/Graduator/View/Cells/SubjectViewCell.swift @@ -33,9 +33,16 @@ 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 { + // DEV: this should be replaced with proper error handling before ever going to prod + print("ERROR: 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..37877bb 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 { @@ -77,16 +76,25 @@ struct UnitView: View { Image(systemName: "plus") } Button(action: { + unitVM.isEdited = false unitsManagerVM.isAllEditable.toggle() unitVM.onEdited(isCancelled: true) + unitVM.SubjectsVM.forEach { $0.onEdited(isCancelled: true) } }) { 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 { + // DEV: this should be replaced with proper error handling before ever going to prod + print("ERROR: Failed to update unit: \(error)") + } + } }) { Text("OK") } @@ -107,9 +115,16 @@ 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 { + // DEV: this should be replaced with proper error handling before ever going to prod + print("ERROR: Failed to create unit: \(error)") + } + } } else { showAlert = true } @@ -124,6 +139,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/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..5f44b52 100644 --- a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift +++ b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift @@ -44,9 +44,8 @@ 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] public var UnitsVM: [UnitVM] { unitsVM } @@ -54,18 +53,39 @@ 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 { + do { + try await original.load() + DispatchQueue.main.async { + self.model = self.original.data + self.unitsVM = self.original.units.map { UnitVM(unit: $0) } + } + } catch { + // DEV: this should be replaced with proper error handling before ever going to prod + 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 + do { + try await original.save() + 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...") + } } var TotalAverage: Double? { 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 ```