💾 Fix #3: Implement (local) persistence #8

Merged
alexis.drai merged 7 commits from feature/add-persistence into main 2 years ago

@ -19,6 +19,9 @@
EC242B882A1FC605006FE760 /* NoGradesInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B872A1FC605006FE760 /* NoGradesInfo.swift */; };
EC242B8A2A1FCECA006FE760 /* AverageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC242B892A1FCECA006FE760 /* AverageBlockView.swift */; };
EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5FE5A42A20882F0028AA5F /* Formatters.swift */; };
EC8BAD162A3465230062226B /* UnitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8BAD152A3465230062226B /* UnitsStore.swift */; };
EC8BAD1A2A34BC170062226B /* WeightedGrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8BAD192A34BC170062226B /* WeightedGrade.swift */; };
EC8BAD1C2A34BE4C0062226B /* WeightedAverageCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8BAD1B2A34BE4C0062226B /* WeightedAverageCalculator.swift */; };
ECB2FFCE2A23C4A700FF9F91 /* SubjectFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB2FFCD2A23C4A700FF9F91 /* SubjectFormView.swift */; };
ECB2FFD02A23C4B700FF9F91 /* SubjectFormVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB2FFCF2A23C4B700FF9F91 /* SubjectFormVM.swift */; };
ECC581D22A1D085B006C55EF /* GraduatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC581D12A1D085B006C55EF /* GraduatorApp.swift */; };
@ -41,6 +44,9 @@
EC242B872A1FC605006FE760 /* NoGradesInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoGradesInfo.swift; sourceTree = "<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>"; };
EC8BAD152A3465230062226B /* UnitsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsStore.swift; sourceTree = "<group>"; };
EC8BAD192A34BC170062226B /* WeightedGrade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightedGrade.swift; sourceTree = "<group>"; };
EC8BAD1B2A34BE4C0062226B /* WeightedAverageCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightedAverageCalculator.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; };
@ -108,6 +114,14 @@
path = Utils;
sourceTree = "<group>";
};
EC8BAD142A34650D0062226B /* Data */ = {
isa = PBXGroup;
children = (
EC8BAD152A3465230062226B /* UnitsStore.swift */,
);
path = Data;
sourceTree = "<group>";
};
ECB2FFCC2A23C49500FF9F91 /* Forms */ = {
isa = PBXGroup;
children = (
@ -135,12 +149,12 @@
ECC581D02A1D085B006C55EF /* Graduator */ = {
isa = PBXGroup;
children = (
EC8BAD142A34650D0062226B /* Data */,
ECE6E3C02A1F80F6004FE471 /* Model */,
ECC581DF2A1D08C3006C55EF /* ViewModel */,
EC242B6F2A1F8260006FE760 /* View */,
ECC581D12A1D085B006C55EF /* GraduatorApp.swift */,
EC242B702A1F8283006FE760 /* MainView.swift */,
EC242B822A1FAA9B006FE760 /* Stub.swift */,
ECC581D52A1D085C006C55EF /* Assets.xcassets */,
ECC581D72A1D085C006C55EF /* Preview Content */,
);
@ -169,9 +183,12 @@
ECE6E3C02A1F80F6004FE471 /* Model */ = {
isa = PBXGroup;
children = (
EC242B822A1FAA9B006FE760 /* Stub.swift */,
EC242B6B2A1F81AE006FE760 /* Subject.swift */,
EC242B692A1F8189006FE760 /* Unit.swift */,
EC242B6D2A1F81CC006FE760 /* UnitsManager.swift */,
EC8BAD192A34BC170062226B /* WeightedGrade.swift */,
EC8BAD1B2A34BE4C0062226B /* WeightedAverageCalculator.swift */,
);
path = Model;
sourceTree = "<group>";
@ -251,8 +268,11 @@
EC242B6E2A1F81CC006FE760 /* UnitsManager.swift in Sources */,
ECC581E52A1D0C44006C55EF /* UnitVM.swift in Sources */,
EC5FE5A52A20882F0028AA5F /* Formatters.swift in Sources */,
EC8BAD162A3465230062226B /* UnitsStore.swift in Sources */,
EC242B7F2A1F83BF006FE760 /* UnitsManagerVM.swift in Sources */,
EC242B7B2A1F838C006FE760 /* UnitViewCell.swift in Sources */,
EC8BAD1C2A34BE4C0062226B /* WeightedAverageCalculator.swift in Sources */,
EC8BAD1A2A34BC170062226B /* WeightedGrade.swift in Sources */,
EC242B6C2A1F81AE006FE760 /* Subject.swift in Sources */,
EC242B8A2A1FCECA006FE760 /* AverageBlockView.swift in Sources */,
ECB2FFD02A23C4B700FF9F91 /* SubjectFormVM.swift in Sources */,

@ -0,0 +1,48 @@
//
// UnitsStore.swift
// Graduator
//
// Created by etudiant on 2023-06-10.
//
import SwiftUI
class UnitsStore: ObservableObject {
private static func fileURL() throws -> URL {
try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
).appendingPathComponent("dat.data.tho")
}
func load<T: Codable>(defaultValue: [T]) async throws -> [T] {
let task = Task<[T], Error> {
let fileURL = try Self.fileURL()
let data = try? Data(contentsOf: fileURL)
var elements: [T] = defaultValue
if let validData = data, !validData.isEmpty {
elements = try JSONDecoder().decode([T].self, from: validData)
}
return elements
}
return try await task.value
}
func save<T: Codable>(elements: [T]) async throws {
let task = Task {
let data = try JSONEncoder().encode(elements)
let outfile = try Self.fileURL()
try data.write(to: outfile)
}
_ = try await task.value
}
}

@ -14,6 +14,13 @@ struct GraduatorApp: App {
var body: some Scene {
WindowGroup {
MainView(unitsManagerVM: unitsManagerVM)
.task {
do {
try await unitsManagerVM.load()
} catch {
fatalError(error.localizedDescription)
}
}
.environmentObject(unitsManagerVM)
}
}

@ -121,7 +121,7 @@ struct Stub {
),
Subject(
id: UUID(),
name: "Architecture de projetc C# .NET (1)",
name: "Architecture de projet C# .NET (1)",
weight: 5,
grade: 14.5/20.0
),
@ -148,7 +148,7 @@ struct Stub {
subjects: [
Subject(
id: UUID(),
name: "Architecture de projetc C# .NET (2)",
name: "Architecture de projet C# .NET (2)",
weight: 4,
grade: 12.17/20.0
),

@ -7,7 +7,7 @@
import Foundation
struct Subject : Identifiable {
struct Subject : Identifiable, Codable, WeightedGrade {
let id: UUID
var name: String
var weight: Int

@ -7,27 +7,16 @@
import Foundation
struct Unit : Identifiable {
struct Unit : Identifiable, Codable, WeightedGrade {
let id: UUID
var name: String
var weight: Int
var grade: Double? { getAverage() }
var isProfessional: Bool
var code: Int
var subjects: [Subject]
func getAverage() -> Double? {
var totalWeight = 0
var weightedSum = 0.0
for subject in subjects {
if let grade = subject.grade {
totalWeight += subject.weight
weightedSum += grade * Double(subject.weight)
}
}
guard totalWeight > 0 else { return nil }
return weightedSum / Double(totalWeight)
return WeightedAverageCalculator.average(elements: subjects)
}
}

@ -10,28 +10,37 @@ import Foundation
struct UnitsManager {
var units: [Unit]
private var store = UnitsStore()
func getTotalAverage() -> Double? {
return getAverage(units: units)
public init(units: [Unit] = [], store: UnitsStore = UnitsStore()) {
self.units = units
self.store = store
}
func getProfessionalAverage() -> Double? {
return getAverage(units: units.filter { $0.isProfessional })
mutating func load() async throws {
do {
self.units = try await store.load(defaultValue: Stub.units)
} catch {
// DEV: this should be replaced with proper error handling before ever going to prod
print("ERROR: Failed to load...")
}
}
func getAverage(units: [Unit]) -> Double? {
var totalWeight = 0
var weightedSum = 0.0
for unit in units {
if let grade = unit.getAverage() {
totalWeight += unit.weight
weightedSum += grade * Double(unit.weight)
func save() async throws {
do {
try await store.save(elements: units)
} catch {
// DEV: this should be replaced with proper error handling before ever going to prod
print("ERROR: Failed to save...")
}
}
guard totalWeight > 0 else { return nil }
func getTotalAverage() -> Double? {
return WeightedAverageCalculator.average(elements: units)
}
return weightedSum / Double(totalWeight)
func getProfessionalAverage() -> Double? {
let professionalUnits = units.filter { $0.isProfessional }
return WeightedAverageCalculator.average(elements: professionalUnits)
}
}

@ -0,0 +1,26 @@
//
// WeightedAverageCalculator.swift
// Graduator
//
// Created by etudiant on 2023-06-10.
//
import Foundation
struct WeightedAverageCalculator {
static func average<T: WeightedGrade>(elements: [T]) -> Double? {
var totalWeight = 0
var weightedSum = 0.0
for element in elements {
if let grade = element.grade {
totalWeight += element.weight
weightedSum += grade * Double(element.weight)
}
}
guard totalWeight > 0 else { return nil }
return weightedSum / Double(totalWeight)
}
}

@ -0,0 +1,13 @@
//
// WeightedGrade.swift
// Graduator
//
// Created by etudiant on 2023-06-10.
//
import Foundation
protocol WeightedGrade {
var weight: Int { get }
var grade: Double? { get }
}

@ -33,9 +33,16 @@ struct SubjectViewCell: View {
.frame(width: 40)
.onChange(of: isGradeEditable) { value in
if !value {
Task {
do {
subjectVM.onEdited()
unitVM.updateSubject(subjectVM)
unitsManagerVM.updateUnit(unitVM)
try await unitsManagerVM.updateUnit(unitVM)
} catch {
// DEV: this should be replaced with proper error handling before ever going to prod
print("ERROR: Failed to update grade: \(error)")
}
}
}
}
Image(systemName: isGradeEditable ? "checkmark" : "lock.open")

@ -19,7 +19,6 @@ struct UnitView: View {
guard let index = offsets.first else { return }
let subjectVMToDelete = unitVM.SubjectsVM[index]
unitVM.deleteSubject(subjectVMToDelete)
unitsManagerVM.updateUnit(unitVM)
}
var body: some View {
@ -77,16 +76,25 @@ struct UnitView: View {
Image(systemName: "plus")
}
Button(action: {
unitVM.isEdited = false
unitsManagerVM.isAllEditable.toggle()
unitVM.onEdited(isCancelled: true)
unitVM.SubjectsVM.forEach { $0.onEdited(isCancelled: true) }
}) {
Text("Annuler")
}
Button(action: {
Task {
do {
unitsManagerVM.isAllEditable.toggle()
unitVM.onEdited()
unitVM.updateAllSubjects()
unitsManagerVM.updateUnit(unitVM)
try await unitsManagerVM.updateUnit(unitVM)
} catch {
// DEV: this should be replaced with proper error handling before ever going to prod
print("ERROR: Failed to update unit: \(error)")
}
}
}) {
Text("OK")
}
@ -107,9 +115,16 @@ struct UnitView: View {
leading: Button("Annuler") { showingForm = false },
trailing: Button("Enregistrer") {
if let newSubject = formVM.createSubject() {
Task {
do {
unitVM.addSubject(newSubject)
unitsManagerVM.updateUnit(unitVM)
try await unitsManagerVM.updateUnit(unitVM)
showingForm = false
} catch {
// DEV: this should be replaced with proper error handling before ever going to prod
print("ERROR: Failed to create unit: \(error)")
}
}
} else {
showAlert = true
}
@ -124,6 +139,7 @@ struct UnitView: View {
// If user navigates back while editing but before clicking 'OK', the changes are cancelled
.onDisappear(perform: {
if unitsManagerVM.isAllEditable {
unitVM.isEdited = false
unitVM.onEdited(isCancelled: true)
unitsManagerVM.isAllEditable = false
}

@ -60,7 +60,7 @@ class UnitVM : ObservableObject, Identifiable {
init(unit: Unit) {
original = unit
model = original.data
subjectsVM = unit.subjects.map { SubjectVM(subject: $0) }
subjectsVM = original.subjects.map { SubjectVM(subject: $0) }
}
convenience init() {

@ -44,7 +44,6 @@ class UnitsManagerVM : ObservableObject {
private var original: UnitsManager
@Published var model: UnitsManager.Data
@Published var isEdited: Bool = false
@Published var isAllEditable: Bool = false
private var unitsVM: [UnitVM]
@ -54,18 +53,39 @@ class UnitsManagerVM : ObservableObject {
init(unitsManager: UnitsManager) {
original = unitsManager
model = original.data
unitsVM = unitsManager.units.map { UnitVM(unit: $0) }
unitsVM = original.units.map { UnitVM(unit: $0) }
}
convenience init() {
self.init(unitsManager: UnitsManager(units: []))
}
func updateUnit(_ unitVM: UnitVM) {
func load() async throws {
do {
try await original.load()
DispatchQueue.main.async {
self.model = self.original.data
self.unitsVM = self.original.units.map { UnitVM(unit: $0) }
}
} catch {
// DEV: this should be replaced with proper error handling before ever going to prod
print("ERROR: Failed to load VM...")
}
}
func updateUnit(_ unitVM: UnitVM) async throws {
guard let index = unitsVM.firstIndex(where: { $0.id == unitVM.id }) else { return }
let updatedUnit = unitsVM[index].model
original.units[index].update(from: updatedUnit)
model = original.data
do {
try await original.save()
DispatchQueue.main.async {
self.model = self.original.data
}
} catch {
// DEV: this should be replaced with proper error handling before ever going to prod
print("ERROR: Failed to save VM...")
}
}
var TotalAverage: Double? {

@ -9,6 +9,14 @@ Graduator is an iOS application developed with SwiftUI that helps users manage t
Beyond those basic features, some details need to be specified here.
### Persistence
The data is set to be persisted, unless you're using XCode's previewer canvas.
The local persistence solution has been tested manually on the iOS Simulator.
Upon first launching the app, it is set up to load a stub.
### Weighted average
A weighted average means that a `subject` or `unit`'s weight plays a part in calculating the average. Users can observe that increasing the weight of a `subject`, for instance, will make the average of the parent `unit` tend more towards that `subject`'s grade.
@ -39,6 +47,7 @@ After a grade was changed, in order to save the change and to see it reflected i
Finally, users can create a `subject` when in edit mode. After clicking on *'Modifier'*, look for a `+` in the top navigation bar.
<img src="./docs/delete_2.png" height="700" style="margin:20px" alt="subject deleted">
<img src="./docs/create_1.png" height="700" style="margin:20px" alt="creating a subject">
<img src="./docs/create_2.png" height="700" style="margin:20px" alt="subject created">
@ -130,9 +139,9 @@ classDiagram
It might be useful to note that, just like `UnitVM`s aggregate `SubjectVM`s, `Unit`s aggregate
`Subject`s, but these relationship between `Model` entities were removed from the diagram above for clarity.
The same is true with the View-related classes.
The same is true with the `View`-related classes.
Here is the diagram with those relationships depicted.
Here is the diagram with those relationships depicted, and the local persistence solution added.
@ -146,6 +155,7 @@ classDiagram
class UnitsManagerVM {
-original: UnitsManager
+load()
+model: UnitsManager.Data
+isEdited: Bool
+isAllEditable: Bool
@ -175,6 +185,9 @@ classDiagram
class UnitsManager {
-store: UnitsStore
+save()
+load()
+getTotalAverage(): Double?
+getProfessionalAverage(): Double?
+getAverage(units: Unit[]): Double?
@ -199,6 +212,11 @@ classDiagram
+update(from: Data)
}
class UnitsStore {
+load<T: Codable>(defaultValue: T[])
+save<T: Codable>(elements: T[])
}
MainView --> "*" UnitView
MainView --> UnitsManagerVM
UnitView --> "*" SubjectViewCell
@ -215,5 +233,8 @@ classDiagram
SubjectVM --> Subject
UnitsManager --> "*" Unit
UnitsManager --> UnitsStore
UnitsManager --> Stub
Stub --> "*" Unit
Unit --> "*" Subject
```

Loading…
Cancel
Save