Create MVVM Graduator app #1

Merged
alexis.drai merged 3 commits from genesis into main 2 years ago

@ -18,6 +18,9 @@
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 */; };
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 */; };
@ -37,6 +40,9 @@
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>"; };
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>"; };
ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubjectFormView.swift; sourceTree = "<group>"; };
ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubjectFormVM.swift; sourceTree = "<group>"; };
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>"; };
ECC581D52A1D085C006C55EF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -59,7 +65,9 @@
EC242B6F2A1F8260006FE760 /* View */ = {
isa = PBXGroup;
children = (
EC242B862A1FC5EC006FE760 /* Bits */,
ECB2FFCC2A23C49500FF9F91 /* Forms */,
EC5FE5A32A20881B0028AA5F /* Utils */,
EC242B862A1FC5EC006FE760 /* Components */,
EC242B772A1F834C006FE760 /* Cells */,
EC242B762A1F8345006FE760 /* Views */,
);
@ -83,13 +91,29 @@
path = Cells;
sourceTree = "<group>";
};
EC242B862A1FC5EC006FE760 /* Bits */ = {
EC242B862A1FC5EC006FE760 /* Components */ = {
isa = PBXGroup;
children = (
EC242B872A1FC605006FE760 /* NoGradesInfo.swift */,
EC242B892A1FCECA006FE760 /* AverageBlockView.swift */,
);
path = Bits;
path = Components;
sourceTree = "<group>";
};
EC5FE5A32A20881B0028AA5F /* Utils */ = {
isa = PBXGroup;
children = (
EC5FE5A42A20882F0028AA5F /* Formatters.swift */,
);
path = Utils;
sourceTree = "<group>";
};
ECB2FFCC2A23C49500FF9F91 /* Forms */ = {
isa = PBXGroup;
children = (
ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */,
);
path = Forms;
sourceTree = "<group>";
};
ECC581C52A1D085B006C55EF = {
@ -137,6 +161,7 @@
ECC581E02A1D08DB006C55EF /* SubjectVM.swift */,
ECC581E42A1D0C44006C55EF /* UnitVM.swift */,
EC242B7E2A1F83BF006FE760 /* UnitsManagerVM.swift */,
ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */,
);
path = ViewModel;
sourceTree = "<group>";
@ -221,13 +246,16 @@
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 */,
EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */,
EC242B7F2A1F83BF006FE760 /* UnitsManagerVM.swift in Sources */,
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 */,

@ -44,10 +44,15 @@ struct MainView: View {
.padding()
VStack(alignment: .leading) {
ForEach(unitsManagerVM.UnitsVM, id: \.model.id) { unitVM in
NavigationLink(
destination: UnitView(unitVM: unitVM)) {
UnitViewCell(unitVM: unitVM)
ForEach(unitsManagerVM.UnitsVM) { unitVM in
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)
}
}

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

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

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

@ -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
),

@ -9,106 +9,96 @@ 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
private let gradeFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
return formatter
}()
@State private var isGradeEditable = false
var body: some View {
HStack {
if isEditable {
VStack {
Image(systemName: "checkmark.square")
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: gradeFormatter)
), formatter: Formatters.gradeFormatter)
.frame(width: 50)
.disabled(!isEditable)
.disabled(!isGradeEditable || subjectVM.model.isCalled)
VStack {
Toggle("", isOn: $subjectVM.model.isCalled)
.frame(width: 40)
Image(systemName: "snowflake.circle.fill")
.foregroundColor(subjectVM.model.isCalled ? .primary : .gray)
}
Toggle("", isOn: $subjectVM.model.isCalled)
.frame(width: 50)
.disabled(!isEditable)
} 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)
}
}
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
)
}
}

@ -9,29 +9,40 @@ import SwiftUI
struct UnitViewCell: View {
@ObservedObject var unitVM: UnitVM
@ObservedObject var unitsManagerVM: UnitsManagerVM
var body: some View {
VStack {
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)
}
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))
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()
}
}
}
.padding()
@ -42,7 +53,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
)
}
}

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

@ -11,8 +11,14 @@ struct NoGradesInfo: View {
var body: some View {
HStack {
Text("Aucune note enregistrée")
.font(.caption)
Spacer()
}
}
}
struct NoGradesInfo_Previews: PreviewProvider {
static var previews: some View {
NoGradesInfo()
}
}

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

@ -0,0 +1,26 @@
//
// 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
formatter.minimum = 0
formatter.maximum = 20
return formatter
}()
static let weightFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .none
formatter.minimum = 1
return formatter
}()
}

@ -9,53 +9,140 @@ import SwiftUI
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) {
Text("Unit title")
HStack {
Text("UE " + String(unitVM.model.code))
Text(unitVM.model.name)
}
.font(.title)
.padding()
UnitViewCell(unitVM: unitVM)
UnitViewCell(
unitVM: unitVM,
unitsManagerVM: unitsManagerVM
)
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)
SubjectViewCell(subjectVM: subjectVM)
}
}
.navigationBarItems(trailing: Button(action: {
// TODO later: Add action for button. Make editable
// * unit weight
// * unit description
// * subjects
// * make all fields editable (just toggle isEditable is the SubjectCellView?)
// * 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)
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: 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("Edit")
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
}
})
}
}
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[5]))
UnitView(
unitVM: ManagerVMStub.UnitsVM[0],
unitsManagerVM: ManagerVMStub
)
}
}

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

@ -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,
@ -35,21 +27,30 @@ 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 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 }
if (!data.name.isEmpty) {
self.name = data.name
self.weight = data.weight
self.grade = data.grade
}
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 {
var original: Subject
class SubjectVM : ObservableObject, Identifiable {
private var original: Subject
var id: UUID { original.id }
@Published var model: Subject.Data
@Published var isEdited: Bool = false
@ -58,17 +59,11 @@ class SubjectVM : ObservableObject {
model = original.data
}
init(subjectData: Subject.Data) {
self.original = Subject(subjectData: subjectData)
self.model = subjectData
}
convenience init() {
self.init(subject: Subject(
id: UUID(),
name: "",
weight: 1,
grade: 10.0,
isCalled: false
))
}
@ -78,15 +73,11 @@ 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){
if(!isCancelled && original.gradeIsValid(model.grade)){
original.update(from: model)
}
model = original.data
isEdited = false
}
var HasGrade: Bool {
return model.grade != nil
}
}

@ -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,16 +24,16 @@ extension Unit {
weight: self.weight,
isProfessional: self.isProfessional,
code: self.code,
subjects: self.subjects.map{ $0.data }
subjects: self.subjects.map { $0.data }
)
}
// 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}
if (!data.name.isEmpty) {
self.name = data.name
self.weight = data.weight
}
self.weight = max(abs(data.weight), 1)
self.isProfessional = data.isProfessional
self.code = data.code
self.subjects = data.subjects.map {
@ -48,15 +48,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
private var subjectsVM: [SubjectVM]
public var SubjectsVM: [SubjectVM] { subjectsVM }
init(unit: Unit) {
original = unit
model = original.data
subjectsVM = unit.subjects.map { SubjectVM(subject: $0) }
}
convenience init() {
@ -76,32 +81,44 @@ class UnitVM : ObservableObject {
}
func onEdited(isCancelled: Bool = false) {
if(!isCancelled && isEdited){
if(!isCancelled){
original.update(from: model)
}
model = original.data
isEdited = false
}
// TODO Maybe move this to the model?
var Average: Double? {
var totalWeight = 0
var weightedSum = 0.0
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
}
for subject in model.subjects {
if let grade = subject.grade {
totalWeight += subject.weight
weightedSum += grade * Double(subject.weight)
func updateAllSubjects() {
for subjectVM in subjectsVM {
updateSubject(subjectVM)
}
}
guard totalWeight > 0 else { return nil }
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
}
return weightedSum / Double(totalWeight)
var Average: Double? {
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 original.subjects.allSatisfy { $0.isCalled }
}
}

@ -14,7 +14,7 @@ extension UnitsManager {
var data: Data {
Data(
units: self.getUnits().map{ $0.data }
units: self.units.map{ $0.data }
)
}
@ -43,76 +43,37 @@ extension UnitsManager {
class UnitsManagerVM : ObservableObject {
var original: UnitsManager
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
}
public var UnitsVM: [UnitVM] { unitsVM }
init(unitsManager: UnitsManager) {
original = unitsManager
model = original.data
unitsVM = unitsManager.getUnits().map {
UnitVM(unit: $0)
}
unitsVM = unitsManager.units.map { UnitVM(unit: $0) }
}
convenience init() {
self.init(unitsManager: UnitsManager(units: Stub.units))
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? {
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)
}
return original.getProfessionalAverage()
}
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
}
}
*/
}

@ -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
```

Loading…
Cancel
Save