🚧 Implement MVVM for Units

pull/1/head
Alexis Drai 2 years ago
parent 0e4beec348
commit 3c3e30eb7d

@ -18,6 +18,7 @@
EC242B832A1FAA9B006FE760 /* Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B822A1FAA9B006FE760 /* Stub.swift */; }; EC242B832A1FAA9B006FE760 /* Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B822A1FAA9B006FE760 /* Stub.swift */; };
EC242B882A1FC605006FE760 /* NoGradesInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B872A1FC605006FE760 /* NoGradesInfo.swift */; }; EC242B882A1FC605006FE760 /* NoGradesInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B872A1FC605006FE760 /* NoGradesInfo.swift */; };
EC242B8A2A1FCECA006FE760 /* AverageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B892A1FCECA006FE760 /* AverageBlockView.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 */; }; ECC581D22A1D085B006C55EF /* GraduatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC581D12A1D085B006C55EF /* GraduatorApp.swift */; };
ECC581D62A1D085C006C55EF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC581D52A1D085C006C55EF /* Assets.xcassets */; }; ECC581D62A1D085C006C55EF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC581D52A1D085C006C55EF /* Assets.xcassets */; };
ECC581D92A1D085C006C55EF /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ECC581D82A1D085C006C55EF /* Preview 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 = "<group>"; }; EC242B822A1FAA9B006FE760 /* Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stub.swift; sourceTree = "<group>"; };
EC242B872A1FC605006FE760 /* NoGradesInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoGradesInfo.swift; sourceTree = "<group>"; }; EC242B872A1FC605006FE760 /* NoGradesInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoGradesInfo.swift; sourceTree = "<group>"; };
EC242B892A1FCECA006FE760 /* AverageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AverageBlockView.swift; sourceTree = "<group>"; }; EC242B892A1FCECA006FE760 /* AverageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AverageBlockView.swift; sourceTree = "<group>"; };
EC5FE5A42A20882F0028AA5F /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = "<group>"; };
ECC581CE2A1D085B006C55EF /* Graduator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Graduator.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; ECC581D12A1D085B006C55EF /* GraduatorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraduatorApp.swift; sourceTree = "<group>"; };
ECC581D52A1D085C006C55EF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; ECC581D52A1D085C006C55EF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -59,7 +61,8 @@
EC242B6F2A1F8260006FE760 /* View */ = { EC242B6F2A1F8260006FE760 /* View */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EC242B862A1FC5EC006FE760 /* Bits */, EC5FE5A32A20881B0028AA5F /* Utils */,
EC242B862A1FC5EC006FE760 /* Components */,
EC242B772A1F834C006FE760 /* Cells */, EC242B772A1F834C006FE760 /* Cells */,
EC242B762A1F8345006FE760 /* Views */, EC242B762A1F8345006FE760 /* Views */,
); );
@ -83,13 +86,21 @@
path = Cells; path = Cells;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EC242B862A1FC5EC006FE760 /* Bits */ = { EC242B862A1FC5EC006FE760 /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EC242B872A1FC605006FE760 /* NoGradesInfo.swift */, EC242B872A1FC605006FE760 /* NoGradesInfo.swift */,
EC242B892A1FCECA006FE760 /* AverageBlockView.swift */, EC242B892A1FCECA006FE760 /* AverageBlockView.swift */,
); );
path = Bits; path = Components;
sourceTree = "<group>";
};
EC5FE5A32A20881B0028AA5F /* Utils */ = {
isa = PBXGroup;
children = (
EC5FE5A42A20882F0028AA5F /* Formatters.swift */,
);
path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
ECC581C52A1D085B006C55EF = { ECC581C52A1D085B006C55EF = {
@ -224,6 +235,7 @@
EC242B712A1F8283006FE760 /* MainView.swift in Sources */, EC242B712A1F8283006FE760 /* MainView.swift in Sources */,
EC242B6E2A1F81CC006FE760 /* UnitsManager.swift in Sources */, EC242B6E2A1F81CC006FE760 /* UnitsManager.swift in Sources */,
ECC581E52A1D0C44006C55EF /* UnitVM.swift in Sources */, ECC581E52A1D0C44006C55EF /* UnitVM.swift in Sources */,
EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */,
EC242B7F2A1F83BF006FE760 /* UnitsManagerVM.swift in Sources */, EC242B7F2A1F83BF006FE760 /* UnitsManagerVM.swift in Sources */,
EC242B7B2A1F838C006FE760 /* UnitViewCell.swift in Sources */, EC242B7B2A1F838C006FE760 /* UnitViewCell.swift in Sources */,
EC242B6C2A1F81AE006FE760 /* Subject.swift in Sources */, EC242B6C2A1F81AE006FE760 /* Subject.swift in Sources */,

@ -44,7 +44,7 @@ struct MainView: View {
.padding() .padding()
VStack(alignment: .leading) { VStack(alignment: .leading) {
ForEach(unitsManagerVM.UnitsVM, id: \.model.id) { unitVM in ForEach(unitsManagerVM.UnitsVM) { unitVM in
NavigationLink( NavigationLink(
destination: UnitView(unitVM: unitVM)) { destination: UnitView(unitVM: unitVM)) {
UnitViewCell(unitVM: unitVM) UnitViewCell(unitVM: unitVM)

@ -13,4 +13,8 @@ struct Subject : Identifiable {
var weight: Int var weight: Int
var grade: Double? var grade: Double?
var isCalled: Bool var isCalled: Bool
func gradeIsValid(_ grade: Double?) -> Bool {
return grade == nil || (grade! >= 0 && grade! <= 1)
}
} }

@ -14,4 +14,20 @@ struct Unit : Identifiable {
var isProfessional: Bool var isProfessional: Bool
var code: Int var code: Int
var subjects: [Subject] 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)
}
} }

@ -15,39 +15,27 @@ struct UnitsManager {
self.units = units self.units = units
} }
func getUnits() -> [Unit] { func getTotalAverage() -> Double? {
return units return getAverage(units: units)
} }
func getUnit(id: UUID) -> Unit? { func getProfessionalAverage() -> Double? {
if let index = getIndex(id: id) { return getAverage(units: units.filter { $0.isProfessional })
return units[index]
} else {
return nil
}
} }
mutating func addUnit(unit: Unit) -> Unit { private func getAverage(units: [Unit]) -> Double? {
units.append(unit) var totalWeight = 0
return unit var weightedSum = 0.0
}
mutating func updateUnit(id: UUID, unit: Unit) -> Unit? { for unit in units {
if let index = getIndex(id: id) { if let grade = unit.getAverage() {
units[index] = unit totalWeight += unit.weight
return unit weightedSum += grade * Double(unit.weight)
} else { }
return nil
} }
}
mutating func removeUnit(id: UUID) { guard totalWeight > 0 else { return nil }
if let index = getIndex(id: id) {
units.remove(at: index)
}
}
private func getIndex(id: UUID) -> Int? { return weightedSum / Double(totalWeight)
return units.firstIndex(where: { $0.id == id })
} }
} }

@ -13,19 +13,18 @@ struct SubjectViewCell: View {
//TODO also allow using the unitview's navigation bar item "Edit" (makes all subjects editable, and more) //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 isEditable = false
private let gradeFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
return formatter
}()
var body: some View { var body: some View {
HStack { HStack {
if isEditable { if isEditable {
VStack { VStack {
Image(systemName: "checkmark.square") Button(action: {
isEditable = false
subjectVM.onEdited()
}) {
Image(systemName: "checkmark.square")
.foregroundColor(.green)
}
Button(action: { Button(action: {
isEditable = false isEditable = false
subjectVM.onEdited(isCancelled: true) subjectVM.onEdited(isCancelled: true)
@ -75,9 +74,9 @@ struct SubjectViewCell: View {
subjectVM.model.grade = newValue / 20.0 subjectVM.model.grade = newValue / 20.0
} }
} }
), formatter: gradeFormatter) ), formatter: Formatters.gradeFormatter)
.frame(width: 50) .frame(width: 50)
.disabled(!isEditable) .disabled(!isEditable || subjectVM.model.isCalled)
Image(systemName: "snowflake.circle.fill") Image(systemName: "snowflake.circle.fill")
.foregroundColor(subjectVM.model.isCalled ? .primary : .gray) .foregroundColor(subjectVM.model.isCalled ? .primary : .gray)

@ -20,17 +20,23 @@ struct UnitViewCell: View {
Text(String(unitVM.model.weight)) Text(String(unitVM.model.weight))
} }
if let average = unitVM.Average { HStack {
HStack { if let average = unitVM.Average {
// TODO add slider linked to "grade" value
// TODO link slider color to the average. If below 10.0, red, else green. ProgressView(value: average, total: 1.0)
Text("Sliiiiiiiiiiiiider") .accentColor(average < 0.5 ? .red : .green)
.background(Color.red) .scaleEffect(x: 1, y: 4, anchor: .center)
Text(String(format: "%.2f", average * 20))
Text(String(format: "%.2f", average * 20.0))
Spacer() Spacer()
Image(systemName: "snowflake.circle.fill")
.foregroundColor(unitVM.IsCalled ? .primary : .gray)
} else {
NoGradesInfo()
} }
} else {
NoGradesInfo()
} }
} }

@ -15,7 +15,7 @@ struct AverageBlockView: View {
HStack { HStack {
Text(title) Text(title)
Spacer() Spacer()
Text(String(format: "%.2f", average * 20)) Text(String(format: "%.2f", average * 20.0))
Image(systemName: average >= 0.5 ? "graduationcap.fill" : "exclamationmark.bubble.fill") Image(systemName: average >= 0.5 ? "graduationcap.fill" : "exclamationmark.bubble.fill")
} }
} }

@ -11,7 +11,6 @@ struct NoGradesInfo: View {
var body: some View { var body: some View {
HStack { HStack {
Text("Aucune note enregistrée") Text("Aucune note enregistrée")
.font(.caption)
Spacer() Spacer()
} }
} }

@ -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
}()
}

@ -21,30 +21,28 @@ struct UnitView: View {
Divider() Divider()
HStack { HStack {
// TODO later add cross image (multiply) Image(systemName: "multiply.circle.fill")
Text("coefficient : " + "weight") Text(String(format: "coefficient : %d", unitVM.model.weight))
} }
.padding(.horizontal) .padding(.horizontal)
HStack { HStack {
// TODO later add page with scribbling image Image(systemName: "magnifyingglass.circle")
Text("Détail des notes") Text("Détail des notes")
} }
.padding(.horizontal) .padding(.horizontal)
ScrollView { ScrollView {
ForEach(unitVM.model.subjects) { subjectData in ForEach(unitVM.subjectVMs) { subjectVM in
// You need to convert subjectData into SubjectVM, then use it to create SubjectViewCell
let subjectVM = SubjectVM(subjectData: subjectData)
SubjectViewCell(subjectVM: subjectVM) SubjectViewCell(subjectVM: subjectVM)
} }
} }
.navigationBarItems(trailing: Button(action: { .navigationBarItems(trailing: Button(action: {
// TODO later: Add action for button. Make editable // TODO Add action for button. Make editable
// * unit weight // * (LATER) unit weight
// * unit description // * (LATER) unit description
// * subjects // * 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) // * 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) // * 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 { struct UnitView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
UnitView(unitVM: UnitVM(unit: Stub.units[5])) UnitView(unitVM: UnitVM(unit: Stub.units[0]))
} }
} }

@ -35,11 +35,12 @@ extension Subject {
} }
mutating func update(from data: Data) { mutating func update(from data: Data) {
// FIXME improve the guard // papers please
guard (data.id == self.data.id guard data.id == self.data.id else { return }
&& (self.isCalled == false || data.isCalled == false) // can't update if this subject is called, unless the update is to 'un-call' the subject
&& (data.grade != nil || data.isCalled == false)) guard !(self.isCalled && data.isCalled) else { return }
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.name = data.name
self.weight = data.weight self.weight = data.weight
@ -48,8 +49,10 @@ extension Subject {
} }
} }
class SubjectVM : ObservableObject { class SubjectVM : ObservableObject, Identifiable {
var original: Subject private var original: Subject
var id: UUID { original.id }
weak var unitVM: UnitVM?
@Published var model: Subject.Data @Published var model: Subject.Data
@Published var isEdited: Bool = false @Published var isEdited: Bool = false
@ -68,7 +71,6 @@ class SubjectVM : ObservableObject {
id: UUID(), id: UUID(),
name: "", name: "",
weight: 1, weight: 1,
grade: 10.0,
isCalled: false isCalled: false
)) ))
} }
@ -78,15 +80,16 @@ class SubjectVM : ObservableObject {
isEdited = true 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) { func onEdited(isCancelled: Bool = false) {
if(!isCancelled && isEdited){ if(!isCancelled && original.gradeIsValid(model.grade)){
original.update(from: model) if (isEdited) {
original.update(from: model)
unitVM?.updateSubjects()
}
}
else {
model = original.data
} }
isEdited = false isEdited = false
} }
var HasGrade: Bool {
return model.grade != nil
}
} }

@ -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) { mutating func update(from data: Data) {
guard self.id == data.id else {return} guard self.id == data.id else {return}
self.name = data.name self.name = data.name
@ -48,15 +46,20 @@ extension Unit {
} }
} }
class UnitVM : ObservableObject { class UnitVM : ObservableObject, Identifiable {
var original: Unit private var original: Unit
var id: UUID { original.id }
@Published var model: Unit.Data @Published var model: Unit.Data
@Published var isEdited: Bool = false @Published var isEdited: Bool = false
@Published var subjectVMs: [SubjectVM]
init(unit: Unit) { init(unit: Unit) {
original = unit original = unit
model = original.data model = original.data
subjectVMs = unit.subjects.map { SubjectVM(subject: $0) }
for subjectVM in subjectVMs {
subjectVM.unitVM = self
}
} }
convenience init() { convenience init() {
@ -76,32 +79,29 @@ class UnitVM : ObservableObject {
} }
func onEdited(isCancelled: Bool = false) { func onEdited(isCancelled: Bool = false) {
if(!isCancelled && isEdited){ if(!isCancelled){
original.update(from: model) if (isEdited) {
original.update(from: model)
// TODO unitsManagerVM?.updateUnits()
}
}
else {
model = original.data
} }
isEdited = false isEdited = false
} }
// TODO Maybe move this to the model? func updateSubjects() {
var Average: Double? { // FIXME neither instruction seems to update the model. At least the unitViewCell wtill displays the old average after we update a grade inside
var totalWeight = 0 objectWillChange.send()
var weightedSum = 0.0 model = original.data
}
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) var Average: Double? {
return original.getAverage()
} }
var isCalled: Bool { var IsCalled: Bool {
// FIXME this is false if any suject therein has a nil grade. This is true if all subjects therein are locked return model.subjects.allSatisfy { $0.isCalled }
// also check if this can stay a function, or if it would be better as a calculated property
return false
} }
} }

@ -14,7 +14,7 @@ extension UnitsManager {
var data: Data { var data: Data {
Data( Data(
units: self.getUnits().map{ $0.data } units: self.units.map{ $0.data }
) )
} }
@ -43,7 +43,7 @@ extension UnitsManager {
class UnitsManagerVM : ObservableObject { class UnitsManagerVM : ObservableObject {
var original: UnitsManager private var original: UnitsManager
@Published var model: UnitsManager.Data @Published var model: UnitsManager.Data
@Published var isEdited: Bool = false @Published var isEdited: Bool = false
@ -56,13 +56,13 @@ class UnitsManagerVM : ObservableObject {
init(unitsManager: UnitsManager) { init(unitsManager: UnitsManager) {
original = unitsManager original = unitsManager
model = original.data model = original.data
unitsVM = unitsManager.getUnits().map { unitsVM = unitsManager.units.map {
UnitVM(unit: $0) UnitVM(unit: $0)
} }
} }
convenience init() { convenience init() {
self.init(unitsManager: UnitsManager(units: Stub.units)) self.init(unitsManager: UnitsManager(units: []))
} }
func onEditing() { func onEditing() {
@ -78,40 +78,12 @@ class UnitsManagerVM : ObservableObject {
} }
var TotalAverage: Double? { var TotalAverage: Double? {
return getAverage(unitsVM: self.unitsVM) return original.getTotalAverage()
} }
var ProfessionalAverage: Double? { var ProfessionalAverage: Double? {
return getAverage(unitsVM: self.unitsVM.filter { $0.model.isProfessional }) return original.getProfessionalAverage()
} }
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
}
}
*/
} }

Loading…
Cancel
Save