From 3c3e30eb7d69c7ec29824e86fdad3ce68e9b4c24 Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Fri, 26 May 2023 10:12:13 +0200 Subject: [PATCH 1/3] :construction: Implement MVVM for Units --- Graduator/Graduator.xcodeproj/project.pbxproj | 18 +++++-- Graduator/Graduator/MainView.swift | 2 +- Graduator/Graduator/Model/Subject.swift | 4 ++ Graduator/Graduator/Model/Unit.swift | 16 ++++++ Graduator/Graduator/Model/UnitsManager.swift | 46 +++++++---------- .../View/Cells/SubjectViewCell.swift | 23 ++++----- .../Graduator/View/Cells/UnitViewCell.swift | 24 +++++---- .../AverageBlockView.swift | 2 +- .../{Bits => Components}/NoGradesInfo.swift | 1 - .../Graduator/View/Utils/Formatters.swift | 17 +++++++ Graduator/Graduator/View/Views/UnitView.swift | 20 ++++---- Graduator/Graduator/ViewModel/SubjectVM.swift | 33 ++++++------ Graduator/Graduator/ViewModel/UnitVM.swift | 50 +++++++++---------- .../Graduator/ViewModel/UnitsManagerVM.swift | 44 +++------------- 14 files changed, 157 insertions(+), 143 deletions(-) rename Graduator/Graduator/View/{Bits => Components}/AverageBlockView.swift (90%) rename Graduator/Graduator/View/{Bits => Components}/NoGradesInfo.swift (88%) create mode 100644 Graduator/Graduator/View/Utils/Formatters.swift diff --git a/Graduator/Graduator.xcodeproj/project.pbxproj b/Graduator/Graduator.xcodeproj/project.pbxproj index 6ff74a0..5701db1 100644 --- a/Graduator/Graduator.xcodeproj/project.pbxproj +++ b/Graduator/Graduator.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ EC242B832A1FAA9B006FE760 /* Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B822A1FAA9B006FE760 /* Stub.swift */; }; 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 */; }; ECC581D22A1D085B006C55EF /* GraduatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC581D12A1D085B006C55EF /* GraduatorApp.swift */; }; ECC581D62A1D085C006C55EF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC581D52A1D085C006C55EF /* Assets.xcassets */; }; ECC581D92A1D085C006C55EF /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC581D82A1D085C006C55EF /* Preview Assets.xcassets */; }; @@ -37,6 +38,7 @@ EC242B822A1FAA9B006FE760 /* Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stub.swift; sourceTree = ""; }; 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 = ""; }; ECC581CE2A1D085B006C55EF /* Graduator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Graduator.app; sourceTree = BUILT_PRODUCTS_DIR; }; ECC581D12A1D085B006C55EF /* GraduatorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraduatorApp.swift; sourceTree = ""; }; ECC581D52A1D085C006C55EF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -59,7 +61,8 @@ EC242B6F2A1F8260006FE760 /* View */ = { isa = PBXGroup; children = ( - EC242B862A1FC5EC006FE760 /* Bits */, + EC5FE5A32A20881B0028AA5F /* Utils */, + EC242B862A1FC5EC006FE760 /* Components */, EC242B772A1F834C006FE760 /* Cells */, EC242B762A1F8345006FE760 /* Views */, ); @@ -83,13 +86,21 @@ path = Cells; sourceTree = ""; }; - EC242B862A1FC5EC006FE760 /* Bits */ = { + EC242B862A1FC5EC006FE760 /* Components */ = { isa = PBXGroup; children = ( EC242B872A1FC605006FE760 /* NoGradesInfo.swift */, EC242B892A1FCECA006FE760 /* AverageBlockView.swift */, ); - path = Bits; + path = Components; + sourceTree = ""; + }; + EC5FE5A32A20881B0028AA5F /* Utils */ = { + isa = PBXGroup; + children = ( + EC5FE5A42A20882F0028AA5F /* Formatters.swift */, + ); + path = Utils; sourceTree = ""; }; ECC581C52A1D085B006C55EF = { @@ -224,6 +235,7 @@ EC242B712A1F8283006FE760 /* MainView.swift in Sources */, EC242B6E2A1F81CC006FE760 /* UnitsManager.swift in Sources */, ECC581E52A1D0C44006C55EF /* UnitVM.swift in Sources */, + EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */, EC242B7F2A1F83BF006FE760 /* UnitsManagerVM.swift in Sources */, EC242B7B2A1F838C006FE760 /* UnitViewCell.swift in Sources */, EC242B6C2A1F81AE006FE760 /* Subject.swift in Sources */, diff --git a/Graduator/Graduator/MainView.swift b/Graduator/Graduator/MainView.swift index e77d44c..fe8c185 100644 --- a/Graduator/Graduator/MainView.swift +++ b/Graduator/Graduator/MainView.swift @@ -44,7 +44,7 @@ struct MainView: View { .padding() VStack(alignment: .leading) { - ForEach(unitsManagerVM.UnitsVM, id: \.model.id) { unitVM in + ForEach(unitsManagerVM.UnitsVM) { unitVM in NavigationLink( destination: UnitView(unitVM: unitVM)) { UnitViewCell(unitVM: unitVM) diff --git a/Graduator/Graduator/Model/Subject.swift b/Graduator/Graduator/Model/Subject.swift index fc1f26e..76451d9 100644 --- a/Graduator/Graduator/Model/Subject.swift +++ b/Graduator/Graduator/Model/Subject.swift @@ -13,4 +13,8 @@ struct Subject : Identifiable { var weight: Int var grade: Double? var isCalled: Bool + + func gradeIsValid(_ grade: Double?) -> Bool { + return grade == nil || (grade! >= 0 && grade! <= 1) + } } diff --git a/Graduator/Graduator/Model/Unit.swift b/Graduator/Graduator/Model/Unit.swift index 28f2f6c..2695214 100644 --- a/Graduator/Graduator/Model/Unit.swift +++ b/Graduator/Graduator/Model/Unit.swift @@ -14,4 +14,20 @@ struct Unit : Identifiable { 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) + } } diff --git a/Graduator/Graduator/Model/UnitsManager.swift b/Graduator/Graduator/Model/UnitsManager.swift index fc9fcae..253902b 100644 --- a/Graduator/Graduator/Model/UnitsManager.swift +++ b/Graduator/Graduator/Model/UnitsManager.swift @@ -15,39 +15,27 @@ struct UnitsManager { self.units = units } - func getUnits() -> [Unit] { - return units + func getTotalAverage() -> Double? { + return getAverage(units: units) } - func getUnit(id: UUID) -> Unit? { - if let index = getIndex(id: id) { - return units[index] - } else { - return nil - } - } - - mutating func addUnit(unit: Unit) -> Unit { - units.append(unit) - return unit - } - - mutating func updateUnit(id: UUID, unit: Unit) -> Unit? { - if let index = getIndex(id: id) { - units[index] = unit - return unit - } else { - return nil - } + func getProfessionalAverage() -> Double? { + return getAverage(units: units.filter { $0.isProfessional }) } + + private func getAverage(units: [Unit]) -> Double? { + var totalWeight = 0 + var weightedSum = 0.0 - mutating func removeUnit(id: UUID) { - if let index = getIndex(id: id) { - units.remove(at: index) + for unit in units { + if let grade = unit.getAverage() { + totalWeight += unit.weight + weightedSum += grade * Double(unit.weight) + } } - } - - private func getIndex(id: UUID) -> Int? { - return units.firstIndex(where: { $0.id == id }) + + guard totalWeight > 0 else { return nil } + + return weightedSum / Double(totalWeight) } } diff --git a/Graduator/Graduator/View/Cells/SubjectViewCell.swift b/Graduator/Graduator/View/Cells/SubjectViewCell.swift index 02198ec..c081807 100644 --- a/Graduator/Graduator/View/Cells/SubjectViewCell.swift +++ b/Graduator/Graduator/View/Cells/SubjectViewCell.swift @@ -11,21 +11,20 @@ struct SubjectViewCell: View { @ObservedObject var subjectVM: SubjectVM //TODO also allow using the unitview's navigation bar item "Edit" (makes all subjects editable, and more) - @State private var isEditable = false - - private let gradeFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 2 - return formatter - }() - + @State private var isEditable = false var body: some View { HStack { if isEditable { VStack { - Image(systemName: "checkmark.square") + Button(action: { + isEditable = false + subjectVM.onEdited() + }) { + Image(systemName: "checkmark.square") + .foregroundColor(.green) + } + Button(action: { isEditable = false subjectVM.onEdited(isCancelled: true) @@ -75,9 +74,9 @@ struct SubjectViewCell: View { subjectVM.model.grade = newValue / 20.0 } } - ), formatter: gradeFormatter) + ), formatter: Formatters.gradeFormatter) .frame(width: 50) - .disabled(!isEditable) + .disabled(!isEditable || subjectVM.model.isCalled) Image(systemName: "snowflake.circle.fill") .foregroundColor(subjectVM.model.isCalled ? .primary : .gray) diff --git a/Graduator/Graduator/View/Cells/UnitViewCell.swift b/Graduator/Graduator/View/Cells/UnitViewCell.swift index fca53cf..6b8f67e 100644 --- a/Graduator/Graduator/View/Cells/UnitViewCell.swift +++ b/Graduator/Graduator/View/Cells/UnitViewCell.swift @@ -20,17 +20,23 @@ struct UnitViewCell: View { Text(String(unitVM.model.weight)) } - if let average = unitVM.Average { - HStack { - // TODO add slider linked to "grade" value - // TODO link slider color to the average. If below 10.0, red, else green. - Text("Sliiiiiiiiiiiiider") - .background(Color.red) - Text(String(format: "%.2f", average * 20)) + HStack { + if let average = unitVM.Average { + + ProgressView(value: average, total: 1.0) + .accentColor(average < 0.5 ? .red : .green) + .scaleEffect(x: 1, y: 4, anchor: .center) + + Text(String(format: "%.2f", average * 20.0)) + Spacer() + + Image(systemName: "snowflake.circle.fill") + .foregroundColor(unitVM.IsCalled ? .primary : .gray) + + } else { + NoGradesInfo() } - } else { - NoGradesInfo() } } diff --git a/Graduator/Graduator/View/Bits/AverageBlockView.swift b/Graduator/Graduator/View/Components/AverageBlockView.swift similarity index 90% rename from Graduator/Graduator/View/Bits/AverageBlockView.swift rename to Graduator/Graduator/View/Components/AverageBlockView.swift index 58730c2..ed6ac63 100644 --- a/Graduator/Graduator/View/Bits/AverageBlockView.swift +++ b/Graduator/Graduator/View/Components/AverageBlockView.swift @@ -15,7 +15,7 @@ struct AverageBlockView: View { HStack { Text(title) Spacer() - Text(String(format: "%.2f", average * 20)) + Text(String(format: "%.2f", average * 20.0)) Image(systemName: average >= 0.5 ? "graduationcap.fill" : "exclamationmark.bubble.fill") } } diff --git a/Graduator/Graduator/View/Bits/NoGradesInfo.swift b/Graduator/Graduator/View/Components/NoGradesInfo.swift similarity index 88% rename from Graduator/Graduator/View/Bits/NoGradesInfo.swift rename to Graduator/Graduator/View/Components/NoGradesInfo.swift index b54d6bf..878a2ee 100644 --- a/Graduator/Graduator/View/Bits/NoGradesInfo.swift +++ b/Graduator/Graduator/View/Components/NoGradesInfo.swift @@ -11,7 +11,6 @@ struct NoGradesInfo: View { var body: some View { HStack { Text("Aucune note enregistrée") - .font(.caption) Spacer() } } diff --git a/Graduator/Graduator/View/Utils/Formatters.swift b/Graduator/Graduator/View/Utils/Formatters.swift new file mode 100644 index 0000000..726094e --- /dev/null +++ b/Graduator/Graduator/View/Utils/Formatters.swift @@ -0,0 +1,17 @@ +// +// Formatters.swift +// Graduator +// +// Created by etudiant on 2023-05-26. +// + +import Foundation + +class Formatters { + static let gradeFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + return formatter + }() +} diff --git a/Graduator/Graduator/View/Views/UnitView.swift b/Graduator/Graduator/View/Views/UnitView.swift index 87a2eea..8bb8aec 100644 --- a/Graduator/Graduator/View/Views/UnitView.swift +++ b/Graduator/Graduator/View/Views/UnitView.swift @@ -21,30 +21,28 @@ struct UnitView: View { Divider() HStack { - // TODO later add cross image (multiply) - Text("coefficient : " + "weight") + Image(systemName: "multiply.circle.fill") + Text(String(format: "coefficient : %d", unitVM.model.weight)) } .padding(.horizontal) HStack { - // TODO later add page with scribbling image + Image(systemName: "magnifyingglass.circle") Text("Détail des notes") } .padding(.horizontal) ScrollView { - ForEach(unitVM.model.subjects) { subjectData in - // You need to convert subjectData into SubjectVM, then use it to create SubjectViewCell - let subjectVM = SubjectVM(subjectData: subjectData) + ForEach(unitVM.subjectVMs) { subjectVM in SubjectViewCell(subjectVM: subjectVM) } } .navigationBarItems(trailing: Button(action: { - // TODO later: Add action for button. Make editable - // * unit weight - // * unit description + // TODO Add action for button. Make editable + // * (LATER) unit weight + // * (LATER) unit description // * subjects - // * make all fields editable (just toggle isEditable is the SubjectCellView?) + // * make all fields editable (=> just toggle isEditable is the SubjectCellViews?) // * create new subject (creation screen with simple form for name, weight, code, isCalled. Of course, will need to deal with adding it to the unitVM, updating the unitVM, and updating the unitsmanagerVM with the new unitVM. Check the result to make sure that the model does get updated by the VM in the end) // * delete a subject (again, this has repercusions for the unit and the unitmanager, be careful) }) { @@ -56,6 +54,6 @@ struct UnitView: View { struct UnitView_Previews: PreviewProvider { static var previews: some View { - UnitView(unitVM: UnitVM(unit: Stub.units[5])) + UnitView(unitVM: UnitVM(unit: Stub.units[0])) } } diff --git a/Graduator/Graduator/ViewModel/SubjectVM.swift b/Graduator/Graduator/ViewModel/SubjectVM.swift index 5e0b90a..a10fe6c 100644 --- a/Graduator/Graduator/ViewModel/SubjectVM.swift +++ b/Graduator/Graduator/ViewModel/SubjectVM.swift @@ -35,11 +35,12 @@ extension Subject { } mutating func update(from data: Data) { - // FIXME improve the guard - guard (data.id == self.data.id - && (self.isCalled == false || data.isCalled == false) - && (data.grade != nil || data.isCalled == false)) - else { return } + // papers please + guard data.id == self.data.id else { return } + // can't update if this subject is called, unless the update is to 'un-call' the subject + guard !(self.isCalled && data.isCalled) else { return } + // can't update a subject to become called and have a nil grade at the same time + guard !(data.grade == nil && data.isCalled) else { return } self.name = data.name self.weight = data.weight @@ -48,8 +49,10 @@ extension Subject { } } -class SubjectVM : ObservableObject { - var original: Subject +class SubjectVM : ObservableObject, Identifiable { + private var original: Subject + var id: UUID { original.id } + weak var unitVM: UnitVM? @Published var model: Subject.Data @Published var isEdited: Bool = false @@ -68,7 +71,6 @@ class SubjectVM : ObservableObject { id: UUID(), name: "", weight: 1, - grade: 10.0, isCalled: false )) } @@ -78,15 +80,16 @@ class SubjectVM : ObservableObject { isEdited = true } - // TODO add suitable error handling for cases where the user enters an invalid number. (negative numbers are forbidden... Do we need to guard against NaN and nil as well?) func onEdited(isCancelled: Bool = false) { - if(!isCancelled && isEdited){ - original.update(from: model) + if(!isCancelled && original.gradeIsValid(model.grade)){ + if (isEdited) { + original.update(from: model) + unitVM?.updateSubjects() + } + } + else { + model = original.data } isEdited = false } - - var HasGrade: Bool { - return model.grade != nil - } } diff --git a/Graduator/Graduator/ViewModel/UnitVM.swift b/Graduator/Graduator/ViewModel/UnitVM.swift index d41d29b..5baccc2 100644 --- a/Graduator/Graduator/ViewModel/UnitVM.swift +++ b/Graduator/Graduator/ViewModel/UnitVM.swift @@ -28,8 +28,6 @@ extension Unit { ) } - - // TODO ? Maybe check that we're not setting isCalled to true on a subject with a nil grade. Keep in mind that's what we already did in SubjectVM's update function mutating func update(from data: Data) { guard self.id == data.id else {return} self.name = data.name @@ -48,15 +46,20 @@ extension Unit { } } -class UnitVM : ObservableObject { - var original: Unit +class UnitVM : ObservableObject, Identifiable { + private var original: Unit + var id: UUID { original.id } @Published var model: Unit.Data @Published var isEdited: Bool = false - + @Published var subjectVMs: [SubjectVM] init(unit: Unit) { original = unit model = original.data + subjectVMs = unit.subjects.map { SubjectVM(subject: $0) } + for subjectVM in subjectVMs { + subjectVM.unitVM = self + } } convenience init() { @@ -76,32 +79,29 @@ class UnitVM : ObservableObject { } func onEdited(isCancelled: Bool = false) { - if(!isCancelled && isEdited){ - original.update(from: model) + if(!isCancelled){ + if (isEdited) { + original.update(from: model) + // TODO unitsManagerVM?.updateUnits() + } + } + else { + model = original.data } isEdited = false } - // TODO Maybe move this to the model? + func updateSubjects() { + // FIXME neither instruction seems to update the model. At least the unitViewCell wtill displays the old average after we update a grade inside + objectWillChange.send() + model = original.data + } + var Average: Double? { - var totalWeight = 0 - var weightedSum = 0.0 - - for subject in model.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 original.getAverage() } - var isCalled: Bool { - // FIXME this is false if any suject therein has a nil grade. This is true if all subjects therein are locked - // also check if this can stay a function, or if it would be better as a calculated property - return false + var IsCalled: Bool { + return model.subjects.allSatisfy { $0.isCalled } } } diff --git a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift index b7b736f..4dcb50c 100644 --- a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift +++ b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift @@ -14,7 +14,7 @@ extension UnitsManager { var data: Data { Data( - units: self.getUnits().map{ $0.data } + units: self.units.map{ $0.data } ) } @@ -43,7 +43,7 @@ extension UnitsManager { class UnitsManagerVM : ObservableObject { - var original: UnitsManager + private var original: UnitsManager @Published var model: UnitsManager.Data @Published var isEdited: Bool = false @@ -52,17 +52,17 @@ class UnitsManagerVM : ObservableObject { public var UnitsVM: [UnitVM] { unitsVM } - + init(unitsManager: UnitsManager) { original = unitsManager model = original.data - unitsVM = unitsManager.getUnits().map { + unitsVM = unitsManager.units.map { UnitVM(unit: $0) } } - + convenience init() { - self.init(unitsManager: UnitsManager(units: Stub.units)) + self.init(unitsManager: UnitsManager(units: [])) } func onEditing() { @@ -78,40 +78,12 @@ class UnitsManagerVM : ObservableObject { } var TotalAverage: Double? { - return getAverage(unitsVM: self.unitsVM) + return original.getTotalAverage() } var ProfessionalAverage: Double? { - return getAverage(unitsVM: self.unitsVM.filter { $0.model.isProfessional }) - } - - private func getAverage(unitsVM: [UnitVM]) -> Double? { - var totalWeight = 0 - var weightedSum = 0.0 - - for unitVM in unitsVM { - if let grade = unitVM.Average { - totalWeight += unitVM.model.weight - weightedSum += grade * Double(unitVM.model.weight) - } - } - - guard totalWeight > 0 else { return nil } - - return weightedSum / Double(totalWeight) - } - - // FIXME fix this nightmare after resting and doing Subject first - /* - func updateUnit(id: UUID, unitVM: UnitVM) -> UnitVM? { - if let index = unitsVM.firstIndex(where: { $0.model.id == id }) { - original.updateUnit(id: id, unit: Unit(unitsVM[index].original)) - return unitVM - } else { - return nil - } + return original.getProfessionalAverage() } - */ } -- 2.36.3 From 7e79638357fe68ef1ca38a8fb11f0848df61609d Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sun, 28 May 2023 17:26:43 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=96=96=20Implement=20MVVM=20using=20c?= =?UTF-8?q?lasses=20instead=20of=20structs=20in=20the=20model,=20and=20giv?= =?UTF-8?q?ing=20UnitVM=20a=20function=20to=20update=20any=20of=20its=20Su?= =?UTF-8?q?bjectVMs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Graduator/Graduator/MainView.swift | 20 +++++++--- Graduator/Graduator/Model/UnitsManager.swift | 6 +-- Graduator/Graduator/Stub.swift | 16 ++++---- .../View/Cells/SubjectViewCell.swift | 18 ++++++++- .../Graduator/View/Cells/UnitViewCell.swift | 11 +++++- .../View/Components/NoGradesInfo.swift | 7 ++++ Graduator/Graduator/View/Views/UnitView.swift | 39 ++++++++++++++----- Graduator/Graduator/ViewModel/SubjectVM.swift | 23 +---------- Graduator/Graduator/ViewModel/UnitVM.swift | 32 +++++++-------- .../Graduator/ViewModel/UnitsManagerVM.swift | 21 +++------- 10 files changed, 109 insertions(+), 84 deletions(-) diff --git a/Graduator/Graduator/MainView.swift b/Graduator/Graduator/MainView.swift index fe8c185..cb541b1 100644 --- a/Graduator/Graduator/MainView.swift +++ b/Graduator/Graduator/MainView.swift @@ -45,9 +45,14 @@ struct MainView: View { VStack(alignment: .leading) { ForEach(unitsManagerVM.UnitsVM) { unitVM in - NavigationLink( - destination: UnitView(unitVM: unitVM)) { - UnitViewCell(unitVM: unitVM) + NavigationLink(destination: UnitView( + unitVM: unitVM, + unitsManagerVM: unitsManagerVM + )) { + UnitViewCell( + unitVM: unitVM, + unitsManagerVM: unitsManagerVM + ) } } } @@ -58,8 +63,13 @@ struct MainView: View { } struct MainView_Previews: PreviewProvider { + static var ManagerVMStub: UnitsManagerVM = UnitsManagerVM( + unitsManager: UnitsManager( + units: Stub.units + ) + ) + static var previews: some View { - MainView(unitsManagerVM: UnitsManagerVM(unitsManager: UnitsManager(units: Stub.units))) + MainView(unitsManagerVM: ManagerVMStub) } } - diff --git a/Graduator/Graduator/Model/UnitsManager.swift b/Graduator/Graduator/Model/UnitsManager.swift index 253902b..9e9766e 100644 --- a/Graduator/Graduator/Model/UnitsManager.swift +++ b/Graduator/Graduator/Model/UnitsManager.swift @@ -10,10 +10,6 @@ import Foundation struct UnitsManager { var units: [Unit] - - init(units: [Unit]) { - self.units = units - } func getTotalAverage() -> Double? { return getAverage(units: units) @@ -23,7 +19,7 @@ struct UnitsManager { return getAverage(units: units.filter { $0.isProfessional }) } - private func getAverage(units: [Unit]) -> Double? { + func getAverage(units: [Unit]) -> Double? { var totalWeight = 0 var weightedSum = 0.0 diff --git a/Graduator/Graduator/Stub.swift b/Graduator/Graduator/Stub.swift index 851a39f..6ea6956 100644 --- a/Graduator/Graduator/Stub.swift +++ b/Graduator/Graduator/Stub.swift @@ -97,23 +97,23 @@ struct Stub { ), Subject( id: UUID(), - name: "Économie", + name: "Communication", weight: 4, - grade: 9.5/20.0, + grade: 17.13/20.0, isCalled: false ), Subject( id: UUID(), - name: "Gestion", - weight: 3, + name: "Économie", + weight: 4, grade: 9.5/20.0, isCalled: false ), Subject( id: UUID(), - name: "Communication", - weight: 4, - grade: 17.13/20.0, + name: "Gestion", + weight: 3, + grade: 9.5/20.0, isCalled: false ), ] @@ -195,7 +195,7 @@ struct Stub { ), Subject( id: UUID(), - name: "MAUI", + name: "Xamarin", weight: 5, isCalled: false ), diff --git a/Graduator/Graduator/View/Cells/SubjectViewCell.swift b/Graduator/Graduator/View/Cells/SubjectViewCell.swift index c081807..5d98061 100644 --- a/Graduator/Graduator/View/Cells/SubjectViewCell.swift +++ b/Graduator/Graduator/View/Cells/SubjectViewCell.swift @@ -9,7 +9,9 @@ import SwiftUI struct SubjectViewCell: View { @ObservedObject var subjectVM: SubjectVM - + @ObservedObject var unitVM: UnitVM + @ObservedObject var unitsManagerVM: UnitsManagerVM + //TODO also allow using the unitview's navigation bar item "Edit" (makes all subjects editable, and more) @State private var isEditable = false @@ -20,6 +22,8 @@ struct SubjectViewCell: View { Button(action: { isEditable = false subjectVM.onEdited() + unitVM.updateSubject(subjectVM) + unitsManagerVM.updateUnit(unitVM) }) { Image(systemName: "checkmark.square") .foregroundColor(.green) @@ -107,7 +111,17 @@ struct SubjectViewCell: View { struct SubjectViewCell_Previews: PreviewProvider { + static var ManagerVMStub: UnitsManagerVM = UnitsManagerVM( + unitsManager: UnitsManager( + units: Stub.units + ) + ) + static var previews: some View { - SubjectViewCell(subjectVM: SubjectVM(subject: Stub.units[0].subjects[0])) + SubjectViewCell( + subjectVM: ManagerVMStub.UnitsVM[0].SubjectsVM[0], + unitVM: ManagerVMStub.UnitsVM[0], + unitsManagerVM: ManagerVMStub + ) } } diff --git a/Graduator/Graduator/View/Cells/UnitViewCell.swift b/Graduator/Graduator/View/Cells/UnitViewCell.swift index 6b8f67e..eae57cc 100644 --- a/Graduator/Graduator/View/Cells/UnitViewCell.swift +++ b/Graduator/Graduator/View/Cells/UnitViewCell.swift @@ -9,6 +9,7 @@ import SwiftUI struct UnitViewCell: View { @ObservedObject var unitVM: UnitVM + @ObservedObject var unitsManagerVM: UnitsManagerVM var body: some View { VStack { @@ -48,7 +49,15 @@ struct UnitViewCell: View { } struct UnitViewCell_Previews: PreviewProvider { + static var ManagerVMStub: UnitsManagerVM = UnitsManagerVM( + unitsManager: UnitsManager( + units: Stub.units + ) + ) static var previews: some View { - UnitViewCell(unitVM: UnitVM(unit: Stub.units[0])) + UnitViewCell( + unitVM: UnitVM(unit: Stub.units[0]), + unitsManagerVM: ManagerVMStub + ) } } diff --git a/Graduator/Graduator/View/Components/NoGradesInfo.swift b/Graduator/Graduator/View/Components/NoGradesInfo.swift index 878a2ee..6d71141 100644 --- a/Graduator/Graduator/View/Components/NoGradesInfo.swift +++ b/Graduator/Graduator/View/Components/NoGradesInfo.swift @@ -15,3 +15,10 @@ struct NoGradesInfo: View { } } } + + +struct NoGradesInfo_Previews: PreviewProvider { + static var previews: some View { + NoGradesInfo() + } +} diff --git a/Graduator/Graduator/View/Views/UnitView.swift b/Graduator/Graduator/View/Views/UnitView.swift index 8bb8aec..5446a8a 100644 --- a/Graduator/Graduator/View/Views/UnitView.swift +++ b/Graduator/Graduator/View/Views/UnitView.swift @@ -9,14 +9,21 @@ import SwiftUI struct UnitView: View { @ObservedObject var unitVM: UnitVM + @ObservedObject var unitsManagerVM: UnitsManagerVM var body: some View { VStack(alignment: .leading) { - Text("Unit title") - .font(.title) - .padding() + HStack { + Text("UE " + String(unitVM.model.code)) + Text(unitVM.model.name) + } + .font(.title) + .padding() - UnitViewCell(unitVM: unitVM) + UnitViewCell( + unitVM: unitVM, + unitsManagerVM: unitsManagerVM + ) Divider() @@ -33,14 +40,18 @@ struct UnitView: View { .padding(.horizontal) ScrollView { - ForEach(unitVM.subjectVMs) { subjectVM in - SubjectViewCell(subjectVM: subjectVM) + ForEach(unitVM.SubjectsVM) { subjectVM in + SubjectViewCell( + subjectVM: subjectVM, + unitVM: unitVM, + unitsManagerVM: unitsManagerVM + ) } } .navigationBarItems(trailing: Button(action: { // TODO Add action for button. Make editable - // * (LATER) unit weight - // * (LATER) unit description + // * (LATER, in UnitViewCell) unit weight + // * (LATER, in UnitViewCell) unit description // * subjects // * make all fields editable (=> just toggle isEditable is the SubjectCellViews?) // * create new subject (creation screen with simple form for name, weight, code, isCalled. Of course, will need to deal with adding it to the unitVM, updating the unitVM, and updating the unitsmanagerVM with the new unitVM. Check the result to make sure that the model does get updated by the VM in the end) @@ -53,7 +64,17 @@ struct UnitView: View { } struct UnitView_Previews: PreviewProvider { + + static var ManagerVMStub: UnitsManagerVM = UnitsManagerVM( + unitsManager: UnitsManager( + units: Stub.units + ) + ) + static var previews: some View { - UnitView(unitVM: UnitVM(unit: Stub.units[0])) + UnitView( + unitVM: ManagerVMStub.UnitsVM[0], + unitsManagerVM: ManagerVMStub + ) } } diff --git a/Graduator/Graduator/ViewModel/SubjectVM.swift b/Graduator/Graduator/ViewModel/SubjectVM.swift index a10fe6c..6587aef 100644 --- a/Graduator/Graduator/ViewModel/SubjectVM.swift +++ b/Graduator/Graduator/ViewModel/SubjectVM.swift @@ -16,14 +16,6 @@ extension Subject { var isCalled: Bool } - init(subjectData: Subject.Data) { - self.id = subjectData.id - self.name = subjectData.name - self.weight = subjectData.weight - self.grade = subjectData.grade - self.isCalled = subjectData.isCalled - } - var data: Data { Data( id: self.id, @@ -52,7 +44,6 @@ extension Subject { class SubjectVM : ObservableObject, Identifiable { private var original: Subject var id: UUID { original.id } - weak var unitVM: UnitVM? @Published var model: Subject.Data @Published var isEdited: Bool = false @@ -61,11 +52,6 @@ class SubjectVM : ObservableObject, Identifiable { model = original.data } - init(subjectData: Subject.Data) { - self.original = Subject(subjectData: subjectData) - self.model = subjectData - } - convenience init() { self.init(subject: Subject( id: UUID(), @@ -82,14 +68,9 @@ class SubjectVM : ObservableObject, Identifiable { func onEdited(isCancelled: Bool = false) { if(!isCancelled && original.gradeIsValid(model.grade)){ - if (isEdited) { - original.update(from: model) - unitVM?.updateSubjects() - } - } - else { - model = original.data + original.update(from: model) } + model = original.data isEdited = false } } diff --git a/Graduator/Graduator/ViewModel/UnitVM.swift b/Graduator/Graduator/ViewModel/UnitVM.swift index 5baccc2..e6f912f 100644 --- a/Graduator/Graduator/ViewModel/UnitVM.swift +++ b/Graduator/Graduator/ViewModel/UnitVM.swift @@ -14,7 +14,7 @@ extension Unit { var weight: Int var isProfessional: Bool var code: Int - public var subjects: [Subject.Data] = [] + var subjects: [Subject.Data] = [] } var data: Data { @@ -24,7 +24,7 @@ extension Unit { weight: self.weight, isProfessional: self.isProfessional, code: self.code, - subjects: self.subjects.map{ $0.data } + subjects: self.subjects.map { $0.data } ) } @@ -51,15 +51,15 @@ class UnitVM : ObservableObject, Identifiable { var id: UUID { original.id } @Published var model: Unit.Data @Published var isEdited: Bool = false - @Published var subjectVMs: [SubjectVM] + + private var subjectsVM: [SubjectVM] + + public var SubjectsVM: [SubjectVM] { subjectsVM } init(unit: Unit) { original = unit model = original.data - subjectVMs = unit.subjects.map { SubjectVM(subject: $0) } - for subjectVM in subjectVMs { - subjectVM.unitVM = self - } + subjectsVM = unit.subjects.map { SubjectVM(subject: $0) } } convenience init() { @@ -80,20 +80,16 @@ class UnitVM : ObservableObject, Identifiable { func onEdited(isCancelled: Bool = false) { if(!isCancelled){ - if (isEdited) { - original.update(from: model) - // TODO unitsManagerVM?.updateUnits() - } - } - else { - model = original.data + original.update(from: model) } + model = original.data isEdited = false } - func updateSubjects() { - // FIXME neither instruction seems to update the model. At least the unitViewCell wtill displays the old average after we update a grade inside - objectWillChange.send() + func updateSubject(_ subjectVM: SubjectVM) { + guard let index = subjectsVM.firstIndex(where: { $0.id == subjectVM.id }) else { return } + let updatedSubject = subjectsVM[index].model + original.subjects[index].update(from: updatedSubject) model = original.data } @@ -102,6 +98,6 @@ class UnitVM : ObservableObject, Identifiable { } var IsCalled: Bool { - return model.subjects.allSatisfy { $0.isCalled } + return original.subjects.allSatisfy { $0.isCalled } } } diff --git a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift index 4dcb50c..988621e 100644 --- a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift +++ b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift @@ -49,32 +49,23 @@ class UnitsManagerVM : ObservableObject { private var unitsVM: [UnitVM] - public var UnitsVM: [UnitVM] { - unitsVM - } + public var UnitsVM: [UnitVM] { unitsVM } init(unitsManager: UnitsManager) { original = unitsManager model = original.data - unitsVM = unitsManager.units.map { - UnitVM(unit: $0) - } + unitsVM = unitsManager.units.map { UnitVM(unit: $0) } } convenience init() { self.init(unitsManager: UnitsManager(units: [])) } - func onEditing() { + func updateUnit(_ unitVM: UnitVM) { + 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 - isEdited = true - } - - func onEdited(isCancelled: Bool = false) { - if(!isCancelled && isEdited){ - original.update(from: model) - } - isEdited = false } var TotalAverage: Double? { -- 2.36.3 From a682c12be7b4a59996f8a0c7d3b6292fc8e6596d Mon Sep 17 00:00:00 2001 From: Alexis Drai Date: Sun, 28 May 2023 23:44:27 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Graduator/Graduator.xcodeproj/project.pbxproj | 16 ++ Graduator/Graduator/MainView.swift | 8 +- .../View/Cells/SubjectViewCell.swift | 87 +++---- .../Graduator/View/Cells/UnitViewCell.swift | 8 +- .../View/Forms/SubjectFormView.swift | 40 ++++ .../Graduator/View/Utils/Formatters.swift | 9 + Graduator/Graduator/View/Views/UnitView.swift | 102 ++++++-- .../Graduator/ViewModel/SubjectFormVM.swift | 38 +++ Graduator/Graduator/ViewModel/SubjectVM.swift | 19 +- Graduator/Graduator/ViewModel/UnitVM.swift | 25 +- .../Graduator/ViewModel/UnitsManagerVM.swift | 4 +- README.md | 223 +++++++++++++++++- 12 files changed, 489 insertions(+), 90 deletions(-) create mode 100644 Graduator/Graduator/View/Forms/SubjectFormView.swift create mode 100644 Graduator/Graduator/ViewModel/SubjectFormVM.swift diff --git a/Graduator/Graduator.xcodeproj/project.pbxproj b/Graduator/Graduator.xcodeproj/project.pbxproj index 5701db1..3a41674 100644 --- a/Graduator/Graduator.xcodeproj/project.pbxproj +++ b/Graduator/Graduator.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 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 */; }; + 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 */; }; ECC581D62A1D085C006C55EF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC581D52A1D085C006C55EF /* Assets.xcassets */; }; ECC581D92A1D085C006C55EF /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC581D82A1D085C006C55EF /* Preview Assets.xcassets */; }; @@ -39,6 +41,8 @@ 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 = ""; }; + 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; }; ECC581D12A1D085B006C55EF /* GraduatorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraduatorApp.swift; sourceTree = ""; }; ECC581D52A1D085C006C55EF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -61,6 +65,7 @@ EC242B6F2A1F8260006FE760 /* View */ = { isa = PBXGroup; children = ( + ECB2FFCC2A23C49500FF9F91 /* Forms */, EC5FE5A32A20881B0028AA5F /* Utils */, EC242B862A1FC5EC006FE760 /* Components */, EC242B772A1F834C006FE760 /* Cells */, @@ -103,6 +108,14 @@ path = Utils; sourceTree = ""; }; + ECB2FFCC2A23C49500FF9F91 /* Forms */ = { + isa = PBXGroup; + children = ( + ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */, + ); + path = Forms; + sourceTree = ""; + }; ECC581C52A1D085B006C55EF = { isa = PBXGroup; children = ( @@ -148,6 +161,7 @@ ECC581E02A1D08DB006C55EF /* SubjectVM.swift */, ECC581E42A1D0C44006C55EF /* UnitVM.swift */, EC242B7E2A1F83BF006FE760 /* UnitsManagerVM.swift */, + ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */, ); path = ViewModel; sourceTree = ""; @@ -232,6 +246,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ECB2FFCE2A23C4A700FF9F91 /* SubjectFormView.swift in Sources */, EC242B712A1F8283006FE760 /* MainView.swift in Sources */, EC242B6E2A1F81CC006FE760 /* UnitsManager.swift in Sources */, ECC581E52A1D0C44006C55EF /* UnitVM.swift in Sources */, @@ -240,6 +255,7 @@ EC242B7B2A1F838C006FE760 /* UnitViewCell.swift in Sources */, EC242B6C2A1F81AE006FE760 /* Subject.swift in Sources */, EC242B8A2A1FCECA006FE760 /* AverageBlockView.swift in Sources */, + ECB2FFD02A23C4B700FF9F91 /* SubjectFormVM.swift in Sources */, ECC581E12A1D08DB006C55EF /* SubjectVM.swift in Sources */, EC242B6A2A1F8189006FE760 /* Unit.swift in Sources */, EC242B752A1F8339006FE760 /* UnitView.swift in Sources */, diff --git a/Graduator/Graduator/MainView.swift b/Graduator/Graduator/MainView.swift index cb541b1..aa0b62f 100644 --- a/Graduator/Graduator/MainView.swift +++ b/Graduator/Graduator/MainView.swift @@ -49,10 +49,10 @@ struct MainView: View { unitVM: unitVM, unitsManagerVM: unitsManagerVM )) { - UnitViewCell( - unitVM: unitVM, - unitsManagerVM: unitsManagerVM - ) + UnitViewCell( + unitVM: unitVM, + unitsManagerVM: unitsManagerVM + ) } } } diff --git a/Graduator/Graduator/View/Cells/SubjectViewCell.swift b/Graduator/Graduator/View/Cells/SubjectViewCell.swift index 5d98061..137edb3 100644 --- a/Graduator/Graduator/View/Cells/SubjectViewCell.swift +++ b/Graduator/Graduator/View/Cells/SubjectViewCell.swift @@ -11,101 +11,78 @@ struct SubjectViewCell: View { @ObservedObject var subjectVM: SubjectVM @ObservedObject var unitVM: UnitVM @ObservedObject var unitsManagerVM: UnitsManagerVM - - //TODO also allow using the unitview's navigation bar item "Edit" (makes all subjects editable, and more) - @State private var isEditable = false - + + @State private var isGradeEditable = false + var body: some View { HStack { - if isEditable { - VStack { - Button(action: { - isEditable = false - subjectVM.onEdited() - unitVM.updateSubject(subjectVM) - unitsManagerVM.updateUnit(unitVM) - }) { - Image(systemName: "checkmark.square") - .foregroundColor(.green) - } - - Button(action: { - isEditable = false - subjectVM.onEdited(isCancelled: true) - }) { - Image(systemName: "nosign.app") - .padding(.top, 10.0) - .foregroundColor(.pink) - } - } - } else { - Button(action: { - isEditable = true - subjectVM.onEditing() - }) { - Image(systemName: "lock") - } - } - VStack { HStack { TextField("", text: $subjectVM.model.name) - .disabled(!isEditable) - + .disabled(!unitsManagerVM.isAllEditable) - TextField("", value: $subjectVM.model.weight, formatter: NumberFormatter()) - .frame(width: 20) - .disabled(!isEditable) + TextField("", value: $subjectVM.model.weight, formatter: Formatters.weightFormatter) + .frame(width: 40) + .disabled(!unitsManagerVM.isAllEditable) } HStack { if let grade = subjectVM.model.grade { + VStack { + Toggle("", isOn: $isGradeEditable) + .frame(width: 40) + .onChange(of: isGradeEditable) { value in + if !value { + subjectVM.onEdited() + unitVM.updateSubject(subjectVM) + unitsManagerVM.updateUnit(unitVM) + } + } + Image(systemName: isGradeEditable ? "checkmark" : "lock.open") + } Slider(value: Binding( get: { grade }, set: { newValue in - if isEditable { + if isGradeEditable { subjectVM.model.grade = newValue } } ), in: 0...1, step: 0.001) .accentColor(grade < 0.5 ? .red : .green) - .disabled(!isEditable || subjectVM.model.isCalled) + .disabled(!isGradeEditable || subjectVM.model.isCalled) TextField("", value: Binding( get: { grade * 20.0 }, set: { newValue in - if isEditable { + if isGradeEditable { subjectVM.model.grade = newValue / 20.0 } } ), formatter: Formatters.gradeFormatter) .frame(width: 50) - .disabled(!isEditable || subjectVM.model.isCalled) + .disabled(!isGradeEditable || subjectVM.model.isCalled) - Image(systemName: "snowflake.circle.fill") - .foregroundColor(subjectVM.model.isCalled ? .primary : .gray) - - Toggle("", isOn: $subjectVM.model.isCalled) - .frame(width: 50) - .disabled(!isEditable) + VStack { + Toggle("", isOn: $subjectVM.model.isCalled) + .frame(width: 40) + Image(systemName: "snowflake.circle.fill") + .foregroundColor(subjectVM.model.isCalled ? .primary : .gray) + } + } else { NoGradesInfo() Button(action: { subjectVM.model.grade = 0.0 + isGradeEditable = true }) { Image(systemName: "pencil.line") } - .disabled(!isEditable) - } } + .padding(.bottom) } } - .padding() - .background(Color.gray.opacity(0.2)) - .cornerRadius(12) - .padding(.horizontal) } } diff --git a/Graduator/Graduator/View/Cells/UnitViewCell.swift b/Graduator/Graduator/View/Cells/UnitViewCell.swift index eae57cc..b6c0f01 100644 --- a/Graduator/Graduator/View/Cells/UnitViewCell.swift +++ b/Graduator/Graduator/View/Cells/UnitViewCell.swift @@ -16,9 +16,13 @@ struct UnitViewCell: View { HStack { Text("UE " + String(unitVM.model.code)) - Text(unitVM.model.name) + .frame(width: 40) + TextField("", text: $unitVM.model.name) + .disabled(!unitsManagerVM.isAllEditable) Spacer() - Text(String(unitVM.model.weight)) + TextField("", value: $unitVM.model.weight, formatter: Formatters.weightFormatter) + .frame(width: 30) + .disabled(!unitsManagerVM.isAllEditable) } HStack { diff --git a/Graduator/Graduator/View/Forms/SubjectFormView.swift b/Graduator/Graduator/View/Forms/SubjectFormView.swift new file mode 100644 index 0000000..91e9ca4 --- /dev/null +++ b/Graduator/Graduator/View/Forms/SubjectFormView.swift @@ -0,0 +1,40 @@ +// +// SubjectForm.swift +// Graduator +// +// Created by etudiant on 2023-05-28. +// + +import SwiftUI + +struct SubjectFormView: View { + @ObservedObject var formVM: SubjectFormVM + + var body: some View { + Form { + HStack { + Text("Nom de la matière:") + Spacer() + TextField("Nom", text: $formVM.name) + } + HStack { + Text("Coefficient:") + Spacer() + TextField("Coefficient", value: $formVM.weight, formatter: NumberFormatter()) + } + HStack { + Text("Note sur 20.0:") + Spacer() + TextField("Note", text: $formVM.gradeString) + } + Toggle("Note définitive?", isOn: $formVM.isCalled) + } + } +} + + +struct SubjectFormView_Previews: PreviewProvider { + static var previews: some View { + SubjectFormView(formVM: SubjectFormVM()) + } +} diff --git a/Graduator/Graduator/View/Utils/Formatters.swift b/Graduator/Graduator/View/Utils/Formatters.swift index 726094e..927127a 100644 --- a/Graduator/Graduator/View/Utils/Formatters.swift +++ b/Graduator/Graduator/View/Utils/Formatters.swift @@ -12,6 +12,15 @@ class Formatters { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 2 + formatter.minimum = 0 + formatter.maximum = 20 + return formatter + }() + + static let weightFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + formatter.minimum = 1 return formatter }() } diff --git a/Graduator/Graduator/View/Views/UnitView.swift b/Graduator/Graduator/View/Views/UnitView.swift index 5446a8a..1605b8a 100644 --- a/Graduator/Graduator/View/Views/UnitView.swift +++ b/Graduator/Graduator/View/Views/UnitView.swift @@ -11,6 +11,17 @@ struct UnitView: View { @ObservedObject var unitVM: UnitVM @ObservedObject var unitsManagerVM: UnitsManagerVM + @State private var showAlert = false + @State private var showingForm: Bool = false + var formVM = SubjectFormVM() + + private func delete(at offsets: IndexSet) { + guard let index = offsets.first else { return } + let subjectVMToDelete = unitVM.SubjectsVM[index] + unitVM.deleteSubject(subjectVMToDelete) + unitsManagerVM.updateUnit(unitVM) + } + var body: some View { VStack(alignment: .leading) { HStack { @@ -39,27 +50,84 @@ struct UnitView: View { } .padding(.horizontal) - ScrollView { - ForEach(unitVM.SubjectsVM) { subjectVM in - SubjectViewCell( - subjectVM: subjectVM, - unitVM: unitVM, - unitsManagerVM: unitsManagerVM - ) + + List { + if unitsManagerVM.isAllEditable { + ForEach(unitVM.SubjectsVM) { subjectVM in + SubjectViewCell( + subjectVM: subjectVM, + unitVM: unitVM, + unitsManagerVM: unitsManagerVM + ) + } + .onDelete(perform: delete) + } else { + ForEach(unitVM.SubjectsVM) { subjectVM in + SubjectViewCell( + subjectVM: subjectVM, + unitVM: unitVM, + unitsManagerVM: unitsManagerVM + ) + } } } - .navigationBarItems(trailing: Button(action: { - // TODO Add action for button. Make editable - // * (LATER, in UnitViewCell) unit weight - // * (LATER, in UnitViewCell) unit description - // * subjects - // * make all fields editable (=> just toggle isEditable is the SubjectCellViews?) - // * create new subject (creation screen with simple form for name, weight, code, isCalled. Of course, will need to deal with adding it to the unitVM, updating the unitVM, and updating the unitsmanagerVM with the new unitVM. Check the result to make sure that the model does get updated by the VM in the end) - // * delete a subject (again, this has repercusions for the unit and the unitmanager, be careful) - }) { - Text("Edit") + .navigationBarItems(trailing: HStack { + if unitsManagerVM.isAllEditable { + Button(action: { showingForm = true }) { + Image(systemName: "plus") + } + Button(action: { + unitsManagerVM.isAllEditable.toggle() + unitVM.onEdited(isCancelled: true) + }) { + Text("Annuler") + } + Button(action: { + unitsManagerVM.isAllEditable.toggle() + unitVM.onEdited() + unitVM.updateAllSubjects() + unitsManagerVM.updateUnit(unitVM) + }) { + Text("OK") + } + } else { + Button(action: { + unitsManagerVM.isAllEditable.toggle() + unitVM.onEditing() + }) { + Text("Modifier") + } + } }) + .sheet(isPresented: $showingForm) { + NavigationView { + SubjectFormView(formVM: formVM) + .navigationTitle("Nouvelle matière") + .navigationBarItems( + leading: Button("Annuler") { showingForm = false }, + trailing: Button("Enregistrer") { + if let newSubject = formVM.createSubject() { + unitVM.addSubject(newSubject) + unitsManagerVM.updateUnit(unitVM) + showingForm = false + } else { + showAlert = true + } + }.alert(isPresented: $showAlert) { + Alert(title: Text("Annulé: matière invalide"), + message: Text("C'est un peu plus compliqué que ça..."), + dismissButton: .default(Text("OK"))) + }) + } + } } + // If user navigates back while editing but before clicking 'OK', the changes are cancelled + .onDisappear(perform: { + if unitsManagerVM.isAllEditable { + unitVM.onEdited(isCancelled: true) + unitsManagerVM.isAllEditable = false + } + }) } } diff --git a/Graduator/Graduator/ViewModel/SubjectFormVM.swift b/Graduator/Graduator/ViewModel/SubjectFormVM.swift new file mode 100644 index 0000000..db260a6 --- /dev/null +++ b/Graduator/Graduator/ViewModel/SubjectFormVM.swift @@ -0,0 +1,38 @@ +// +// SubjectFormVM.swift +// Graduator +// +// Created by etudiant on 2023-05-28. +// + +import Foundation + +class SubjectFormVM: ObservableObject { + @Published var name: String = "" + @Published var weight: Int = 1 + @Published var gradeString: String = "" + @Published var isCalled: Bool = false + + var grade: Double? { + if let gradeOverTwenty = Double(gradeString) { + return gradeOverTwenty / 20.0 + } else { + return nil + } + } + + func isValid(_ subject: Subject) -> Bool { + return !(name.isEmpty || weight <= 0 || !subject.gradeIsValid(grade) || (isCalled && grade == nil)) + } + + func createSubject() -> Subject? { + let subject = Subject( + id: UUID(), + name: name, + weight: weight, + grade: grade, + isCalled: isCalled) + guard isValid(subject) else { return nil } + return subject + } +} diff --git a/Graduator/Graduator/ViewModel/SubjectVM.swift b/Graduator/Graduator/ViewModel/SubjectVM.swift index 6587aef..50a8da5 100644 --- a/Graduator/Graduator/ViewModel/SubjectVM.swift +++ b/Graduator/Graduator/ViewModel/SubjectVM.swift @@ -29,16 +29,23 @@ extension Subject { mutating func update(from data: Data) { // papers please guard data.id == self.data.id else { return } - // can't update if this subject is called, unless the update is to 'un-call' the subject - guard !(self.isCalled && data.isCalled) else { return } + // can't update grade if this subject is called, unless the update is to 'un-call' the subject + guard !(self.isCalled && data.isCalled && self.grade != data.grade) else { return } // can't update a subject to become called and have a nil grade at the same time guard !(data.grade == nil && data.isCalled) else { return } - - self.name = data.name - self.weight = data.weight - self.grade = data.grade + + if (!data.name.isEmpty) { + self.name = data.name + } + self.weight = max(abs(data.weight), 1) + if let grade = data.grade { + self.grade = max(abs(grade), 0.0) + } else { + self.grade = nil + } self.isCalled = data.isCalled } + } class SubjectVM : ObservableObject, Identifiable { diff --git a/Graduator/Graduator/ViewModel/UnitVM.swift b/Graduator/Graduator/ViewModel/UnitVM.swift index e6f912f..9762baf 100644 --- a/Graduator/Graduator/ViewModel/UnitVM.swift +++ b/Graduator/Graduator/ViewModel/UnitVM.swift @@ -30,8 +30,10 @@ extension Unit { mutating func update(from data: Data) { guard self.id == data.id else {return} - self.name = data.name - self.weight = data.weight + if (!data.name.isEmpty) { + self.name = data.name + } + self.weight = max(abs(data.weight), 1) self.isProfessional = data.isProfessional self.code = data.code self.subjects = data.subjects.map { @@ -93,6 +95,25 @@ class UnitVM : ObservableObject, Identifiable { model = original.data } + func updateAllSubjects() { + for subjectVM in subjectsVM { + updateSubject(subjectVM) + } + } + + func deleteSubject(_ subjectVM: SubjectVM) { + guard let index = subjectsVM.firstIndex(where: { $0.id == subjectVM.id }) else { return } + subjectsVM.remove(at: index) + original.subjects.remove(at: index) + model = original.data + } + + func addSubject(_ subject: Subject) { + subjectsVM.append(SubjectVM(subject: subject)) + original.subjects.append(subject) + model = original.data + } + var Average: Double? { return original.getAverage() } diff --git a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift index 988621e..fdfd6bc 100644 --- a/Graduator/Graduator/ViewModel/UnitsManagerVM.swift +++ b/Graduator/Graduator/ViewModel/UnitsManagerVM.swift @@ -46,6 +46,7 @@ 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] @@ -76,6 +77,3 @@ class UnitsManagerVM : ObservableObject { return original.getProfessionalAverage() } } - - - diff --git a/README.md b/README.md index 870d500..1b76c71 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,223 @@ -# Graduator salutes you +# Graduator +Graduator is an iOS application developed with SwiftUI that helps users manage their academic `units`, `subjects`, and grades. Users can add and delete `subjects`, edit the weight and name of `subjects` and `units`, and input grades. The app displays weighted averages and explains the conditions for graduating from the Clermont Auvergne Tech Institute's mobile development BSc in 2023. + +## Features + +Beyond those basic features, some details need to be specified here. + +### isCalled Feature + +The isCalled property, available for each subject, denotes whether the grade of a specific subject has been called or not -- i.e., whether it is a definitive grade or a temporary one. + +From the user's point of view, they can see an image of a snowflake that turns from gray to primary color when a subject grade has been called. A Toggle control allows them to set or unset the isCalled status. If they attempt to change the grade of a subject that has been called, they will not be able to -- the corresponding slider and textfield will remain disabled. + +In the model and view model, the `isCalled` property of `subjects` affects the state of their `unit`. If all of a `unit`'s `subject` grades are called, the `unit` is considered called too. Note that if a `subject`'s grade is not set (nil), the `subject` can't be called. + +### Deleting a Subject + +In the app, users can delete a subject by swiping it off the list, right-to-left. + +Note that when a subject is deleted, it is permanently removed from the system. If a user is in the process of editing and deletes a subject, the deletion occurs immediately upon swiping, not when they save (click 'OK'). If the user chooses to cancel their edits (click 'Annuler'), all other unsaved changes will be discarded, but the deletion of the subject remains. + +### Changing a grade + +Before a user changes a grade, they first need to activate the `lock.open` toggle. If the grade is *called*, they also need to use the `isCalled` toggle. + +After a grade was changed, in order to save the change and to see it reflected iun the weighted average, users need to use the (previously `lock.open`, now) `checkmark` toggle. + +## Architecture + +Graduator is based on the MVVM (Model-View-ViewModel) architectural pattern. The below UML class diagram details the structure of the models, viewmodels, and views for `UnitsManager`, `Unit`, and `Subject`. + +```mermaid +classDiagram + class Unit { + +name: String + +weight: Int + +isProfessional: Bool + +code: Int + +subjects: [Subject] + +getAverage(): Double? + +data: Data + +update(from: Data): Void + } + + class UnitVM { + -original: Unit + +model: Unit.Data + +isEdited: Bool + +SubjectsVM: [SubjectVM] + +onEditing(): Void + +onEdited(isCancelled: Bool): Void + +updateSubject(subjectVM: SubjectVM): Void + +updateAllSubjects(): Void + +deleteSubject(subjectVM: SubjectVM): Void + +addSubject(subject: Subject): Void + +Average: Double? + +IsCalled: Bool + } + + class UnitView { + -unitVM: UnitVM + -unitsManagerVM: UnitsManagerVM + +delete(at: IndexSet): Void + } + + class Subject { + +name: String + +weight: Int + +grade: Double? + +isCalled: Bool + +gradeIsValid(grade: Double?): Bool + +data: Data + +update(from: Data): Void + } + + class SubjectVM { + -original: Subject + +model: Subject.Data + +isEdited: Bool + +onEditing(): Void + +onEdited(isCancelled: Bool): Void + } + + class SubjectViewCell { + -subjectVM: SubjectVM + -unitVM: UnitVM + -unitsManagerVM: UnitsManagerVM + -isGradeEditable: Bool + } + + class UnitsManager { + +units: [Unit] + +getTotalAverage(): Double? + +getProfessionalAverage(): Double? + +getAverage(units: [Unit]): Double? + +data: Data + +update(from: Data): Void + } + + class UnitsManagerVM { + -original: UnitsManager + +model: UnitsManager.Data + +isEdited: Bool + +isAllEditable: Bool + +UnitsVM: [UnitVM] + +updateUnit(unitVM: UnitVM): Void + +TotalAverage: Double? + +ProfessionalAverage: Double? + } + + class MainView { + -unitsManagerVM: UnitsManagerVM + } + + UnitVM -- Unit : Uses + UnitView -- UnitVM : Observes + SubjectVM -- Subject : Uses + SubjectViewCell -- SubjectVM : Observes + UnitVM "1" o-- "*" SubjectVM : Contains + UnitsManagerVM -- UnitsManager : Uses + UnitsManagerVM "1" o-- "*" UnitVM : Contains + MainView -- UnitsManagerVM : Observes +``` + +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. + +Here is the diagram with those relationships depicted. + +```mermaid +classDiagram + class Unit { + +name: String + +weight: Int + +isProfessional: Bool + +code: Int + +subjects: [Subject] + +getAverage(): Double? + +data: Data + +update(from: Data): Void + } + + class UnitVM { + -original: Unit + +model: Unit.Data + +isEdited: Bool + +SubjectsVM: [SubjectVM] + +onEditing(): Void + +onEdited(isCancelled: Bool): Void + +updateSubject(subjectVM: SubjectVM): Void + +updateAllSubjects(): Void + +deleteSubject(subjectVM: SubjectVM): Void + +addSubject(subject: Subject): Void + +Average: Double? + +IsCalled: Bool + } + + class UnitView { + -unitVM: UnitVM + -unitsManagerVM: UnitsManagerVM + +delete(at: IndexSet): Void + } + + class Subject { + +name: String + +weight: Int + +grade: Double? + +isCalled: Bool + +gradeIsValid(grade: Double?): Bool + +data: Data + +update(from: Data): Void + } + + class SubjectVM { + -original: Subject + +model: Subject.Data + +isEdited: Bool + +onEditing(): Void + +onEdited(isCancelled: Bool): Void + } + + class SubjectViewCell { + -subjectVM: SubjectVM + -unitVM: UnitVM + -unitsManagerVM: UnitsManagerVM + -isGradeEditable: Bool + } + + class UnitsManager { + +units: [Unit] + +getTotalAverage(): Double? + +getProfessionalAverage(): Double? + +getAverage(units: [Unit]): Double? + +data: Data + +update(from: Data): Void + } + + class UnitsManagerVM { + -original: UnitsManager + +model: UnitsManager.Data + +isEdited: Bool + +isAllEditable: Bool + +UnitsVM: [UnitVM] + +updateUnit(unitVM: UnitVM): Void + +TotalAverage: Double? + +ProfessionalAverage: Double? + } + + class MainView { + -unitsManagerVM: UnitsManagerVM + } + + UnitVM -- Unit : Uses + UnitView -- UnitVM : Observes + SubjectVM -- Subject : Uses + SubjectViewCell -- SubjectVM : Observes + UnitVM "1" o-- "*" SubjectVM : Contains + Unit "1" o-- "*" Subject : Contains + UnitsManagerVM -- UnitsManager : Uses + UnitsManagerVM "1" o-- "*" UnitVM : Contains + UnitsManager "1" o-- "*" Unit : Contains + MainView -- UnitsManagerVM : Observes +``` -- 2.36.3