Refactor Core Data model to support adding and removing cards in the edit view.

This commit is contained in:
CypherPoet 2020-01-22 07:35:03 -06:00
parent 4cd7256a60
commit cfe895744c
15 changed files with 264 additions and 73 deletions

View File

@ -39,6 +39,9 @@
F378AA1923D7359B00296A76 /* EditDeckView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA1823D7359B00296A76 /* EditDeckView+ViewModel.swift */; };
F378AA1B23D7A06600296A76 /* CountdownTimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA1A23D7A06500296A76 /* CountdownTimerView.swift */; };
F378AA1E23D7A0B900296A76 /* CountdownTimerView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA1D23D7A0B900296A76 /* CountdownTimerView+ViewModel.swift */; };
F378AA2223D7B8D900296A76 /* CardDeck+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA2023D7B8D900296A76 /* CardDeck+CoreDataClass.swift */; };
F378AA2323D7B8D900296A76 /* CardDeck+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA2123D7B8D900296A76 /* CardDeck+CoreDataProperties.swift */; };
F378AA2523D7B91E00296A76 /* CardDeck+Computeds.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA2423D7B91E00296A76 /* CardDeck+Computeds.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -73,6 +76,9 @@
F378AA1823D7359B00296A76 /* EditDeckView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditDeckView+ViewModel.swift"; sourceTree = "<group>"; };
F378AA1A23D7A06500296A76 /* CountdownTimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownTimerView.swift; sourceTree = "<group>"; };
F378AA1D23D7A0B900296A76 /* CountdownTimerView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CountdownTimerView+ViewModel.swift"; sourceTree = "<group>"; };
F378AA2023D7B8D900296A76 /* CardDeck+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardDeck+CoreDataClass.swift"; sourceTree = "<group>"; };
F378AA2123D7B8D900296A76 /* CardDeck+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardDeck+CoreDataProperties.swift"; sourceTree = "<group>"; };
F378AA2423D7B91E00296A76 /* CardDeck+Computeds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardDeck+Computeds.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -188,6 +194,7 @@
F378A9DD23D4396000296A76 /* Models */ = {
isa = PBXGroup;
children = (
F378AA1F23D7B8B900296A76 /* Card Deck */,
F378A9DF23D4399000296A76 /* Card */,
F378A9C223D438AE00296A76 /* Flashzilla.xcdatamodeld */,
);
@ -278,6 +285,16 @@
path = "Countdown Timer";
sourceTree = "<group>";
};
F378AA1F23D7B8B900296A76 /* Card Deck */ = {
isa = PBXGroup;
children = (
F378AA2023D7B8D900296A76 /* CardDeck+CoreDataClass.swift */,
F378AA2423D7B91E00296A76 /* CardDeck+Computeds.swift */,
F378AA2123D7B8D900296A76 /* CardDeck+CoreDataProperties.swift */,
);
path = "Card Deck";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -366,6 +383,7 @@
F378AA0C23D4A5D300296A76 /* View+Stacked.swift in Sources */,
F378A9F723D44E3900296A76 /* Card+CoreDataProperties.swift in Sources */,
F378A9E823D43B6A00296A76 /* CurrentApplication.swift in Sources */,
F378AA2323D7B8D900296A76 /* CardDeck+CoreDataProperties.swift in Sources */,
F378A9FD23D47E1000296A76 /* CardView+ViewModel.swift in Sources */,
F378A9E423D43A1700296A76 /* PreviewData.swift in Sources */,
F378A9DA23D4393100296A76 /* RootView.swift in Sources */,
@ -382,9 +400,11 @@
F378AA1923D7359B00296A76 /* EditDeckView+ViewModel.swift in Sources */,
F378A9F323D43CAA00296A76 /* PreviewData+Cards.swift in Sources */,
F378AA0223D48CB900296A76 /* CardDeckContainerView.swift in Sources */,
F378AA2223D7B8D900296A76 /* CardDeck+CoreDataClass.swift in Sources */,
F378A9FB23D47DD900296A76 /* CardView.swift in Sources */,
F378AA0723D4985200296A76 /* FetchedResultsControlling.swift in Sources */,
F378A9C123D438AE00296A76 /* SceneDelegate.swift in Sources */,
F378AA2523D7B91E00296A76 /* CardDeck+Computeds.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -32,10 +32,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath.
// Add `@Environment(\.managedObjectContext)` in the views that will need the context.
let contentView = RootView()
let entryView = RootView()
.environment(\.managedObjectContext, context)
window.rootViewController = UIHostingController(rootView: contentView)
window.rootViewController = UIHostingController(rootView: entryView)
self.window = window
window.makeKeyAndVisible()

View File

@ -0,0 +1,19 @@
//
// CardDeck+Computeds.swift
// Flashzilla
//
// Created by CypherPoet on 1/21/20.
//
//
import Foundation
extension CardDeck {
var cardsArray: [Card] {
guard let cardSet = cards as? Set<Card> else { return [] }
return Array(cardSet)
}
}

View File

@ -0,0 +1,18 @@
//
// CardDeck+CoreDataClass.swift
// Flashzilla
//
// Created by Brian Sipple on 1/21/20.
// Copyright © 2020 CypherPoet. All rights reserved.
//
//
import Foundation
import CoreData
@objc(CardDeck)
public class CardDeck: NSManagedObject {
}
extension CardDeck: Identifiable {}

View File

@ -0,0 +1,39 @@
//
// CardDeck+CoreDataProperties.swift
// Flashzilla
//
// Created by Brian Sipple on 1/21/20.
// Copyright © 2020 CypherPoet. All rights reserved.
//
//
import Foundation
import CoreData
extension CardDeck {
@nonobjc public class func fetchRequest() -> NSFetchRequest<CardDeck> {
return NSFetchRequest<CardDeck>(entityName: "CardDeck")
}
@NSManaged public var name: String?
@NSManaged public var cards: NSSet?
}
// MARK: Generated accessors for cards
extension CardDeck {
@objc(addCardsObject:)
@NSManaged public func addToCards(_ value: Card)
@objc(removeCardsObject:)
@NSManaged public func removeFromCards(_ value: Card)
@objc(addCards:)
@NSManaged public func addToCards(_ values: NSSet)
@objc(removeCards:)
@NSManaged public func removeFromCards(_ values: NSSet)
}

View File

@ -19,12 +19,31 @@ extension Card {
@NSManaged public var prompt: String?
@NSManaged public var answer: String?
@NSManaged public var decks: NSSet?
@NSManaged public var answerStateValue: Int16
@NSManaged public var answerStateValue: Int16
var answerState: Card.AnswerState {
get { Card.AnswerState(rawValue: answerStateValue)! }
set { answerStateValue = newValue.rawValue }
}
}
// MARK: Generated accessors for decks
extension Card {
@objc(addDecksObject:)
@NSManaged public func addToDecks(_ value: CardDeck)
@objc(removeDecksObject:)
@NSManaged public func removeFromDecks(_ value: CardDeck)
@objc(addDecks:)
@NSManaged public func addToDecks(_ values: NSSet)
@objc(removeDecks:)
@NSManaged public func removeFromDecks(_ values: NSSet)
}

View File

@ -4,6 +4,7 @@
<attribute name="answer" optional="YES" attributeType="String"/>
<attribute name="answerStateValue" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="prompt" optional="YES" attributeType="String"/>
<relationship name="decks" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CardDeck" inverseName="cards" inverseEntity="CardDeck"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="answer"/>
@ -11,7 +12,17 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="CardDeck" representedClassName="CardDeck" syncable="YES">
<attribute name="name" optional="YES" attributeType="String"/>
<relationship name="cards" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Card" inverseName="decks" inverseEntity="Card"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="name"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<elements>
<element name="Card" positionX="-63" positionY="-18" width="128" height="88"/>
<element name="Card" positionX="-63" positionY="-18" width="128" height="103"/>
<element name="CardDeck" positionX="-54" positionY="18" width="128" height="73"/>
</elements>
</model>

View File

@ -48,9 +48,23 @@ extension PreviewData {
static let `default` = card1
static func buildDeck() -> [Card] {
[card3, card1, card2]
}
}
}
extension PreviewData {
enum CardDecks {
static let `default`: CardDeck = {
let deck = CardDeck(context: CurrentApp.coreDataManager.mainContext)
deck.name = "Preview Deck"
for card in [PreviewData.Cards.card3, PreviewData.Cards.card1, PreviewData.Cards.card2] {
deck.addToCards(card)
}
return deck
}()
}
}

View File

@ -13,8 +13,7 @@ import CoreData
enum PreviewData {
static func setupSimulatorPreviewData(in managedObjectContext: NSManagedObjectContext) {
let _ = PreviewData.Cards.buildDeck()
try? managedObjectContext.save()
let _ = PreviewData.CardDecks.default
let _ = try? CurrentApp.coreDataManager.saveContexts()
}
}

View File

@ -0,0 +1,41 @@
//
// CountdownTimerView.swift
// Flashzilla
//
// Created by CypherPoet on 1/21/20.
//
//
import SwiftUI
struct CountdownTimerView {
var viewModel: ViewModel
}
// MARK: - View
extension CountdownTimerView: View {
var body: some View {
Text("Time Remaining: ")
.font(.title)
.foregroundColor(Color("Accent3"))
+ Text(self.viewModel.timeRemainingText)
.font(.title)
.fontWeight(.bold)
.foregroundColor(Color("Accent3"))
}
}
// MARK: - Preview
struct CountdownTimerView_Previews: PreviewProvider {
static var previews: some View {
CountdownTimerView(
viewModel: .init()
)
.environment(\.managedObjectContext, CurrentApp.coreDataManager.mainContext)
}
}

View File

@ -14,24 +14,12 @@ import CoreData
extension CardDeckContainerView {
final class ViewModel: NSObject, FetchedResultsControlling, ObservableObject {
typealias FetchedResult = Card
final class ViewModel: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
internal lazy var fetchRequest: NSFetchRequest<Card> = {
let request: NSFetchRequest<Card> = Card.fetchRequest()
request.sortDescriptors = []
return request
}()
internal lazy var fetchedResultsController: FetchedResultsController = makeFetchedResultsController()
var isTimerActive = true
@ObservedObject var cardDeck: CardDeck
var isTimerActive = false
var roundDuration: TimeInterval
@ -42,16 +30,14 @@ extension CardDeckContainerView {
// MARK: - Init
init(
cardDeck: CardDeck,
roundDuration: TimeInterval = 100.0
) {
self.cardDeck = cardDeck
self.roundDuration = roundDuration
self.timeRemaining = roundDuration
super.init()
self.fetchedResultsController.delegate = self
setupSubscribers()
fetchCards()
}
}
}
@ -70,12 +56,21 @@ extension CardDeckContainerView.ViewModel {
private var timeRemainingPublisher: AnyPublisher<TimeInterval, Never> {
roundTickPublisher
.drop(while: { _ in !self.isTimerActive })
.map { _ in
self.isTimerActive ? max(0, self.timeRemaining - 1.0) : self.timeRemaining
}
.removeDuplicates()
.eraseToAnyPublisher()
}
private var cardsPublisher: Publishers.Share<AnyPublisher<[Card], Never>> {
cardDeck.publisher(for: \.cards)
.map { _ in self.cardDeck.cardsArray }
.eraseToAnyPublisher()
.share()
}
}
@ -94,12 +89,6 @@ extension CardDeckContainerView.ViewModel {
// MARK: - Public Methods
extension CardDeckContainerView.ViewModel {
func fetchCards() {
try? fetchedResultsController.performFetch()
cards = extractResults(from: fetchedResultsController)
}
func resetDeck() {
cards.forEach { $0.answerState = .unanswered }
@ -114,6 +103,20 @@ extension CardDeckContainerView.ViewModel {
func resumeRound() {
isTimerActive = true
}
func record(_ answerState: Card.AnswerState, forCardAt index: Int) {
let card = cards[index]
guard let managedObjectContext = card.managedObjectContext else { fatalError() }
card.answerState = answerState
CurrentApp.coreDataManager.save(managedObjectContext)
if isDeckEmpty {
pauseRound()
}
}
}
@ -122,6 +125,11 @@ extension CardDeckContainerView.ViewModel {
private extension CardDeckContainerView.ViewModel {
func setupSubscribers() {
cardsPublisher
.receive(on: DispatchQueue.main)
.assign(to: \.cards, on: self)
.store(in: &subscriptions)
timeRemainingPublisher
.receive(on: DispatchQueue.main)
.assign(to: \.timeRemaining, on: self)
@ -140,15 +148,3 @@ private extension CardDeckContainerView.ViewModel {
.store(in: &subscriptions)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension CardDeckContainerView.ViewModel: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard let controller = controller as? FetchedResultsController else { return }
print("controllerDidChangeContent")
cards = extractResults(from: controller)
}
}

View File

@ -11,7 +11,7 @@ import CypherPoetSwiftUIKit
struct CardDeckContainerView {
@ObservedObject var viewModel: ViewModel = .init()
@ObservedObject var viewModel: ViewModel
@State private var isShowingEditView = false
}
@ -24,6 +24,11 @@ extension CardDeckContainerView: View {
GeometryReader { geometry in
ZStack {
VStack(spacing: 32) {
// 📝: It can be tricky having `CardDeckContainerView` contain the timer, because SwiftUI
// will re-render it on every tick.
// Perhaps it would be better to have `CountdownTimerView` own its timer
// and drive it with `timeRemaining`?
CountdownTimerView(
viewModel: .init(timeRemaining: self.viewModel.timeRemaining)
)
@ -32,7 +37,9 @@ extension CardDeckContainerView: View {
width: min(max(800, geometry.size.width) * 0.8, 480),
height: min(max(800, geometry.size.width) * 0.8, 480) * 0.5,
cards: self.viewModel.visibleCards,
cardAnswered: { (answerState, index) in self.record(answerState, forCardAt: index) }
cardAnswered: { (answerState, index) in
self.viewModel.record(answerState, forCardAt: index)
}
)
.allowsHitTesting(self.viewModel.timeRemaining > 0)
}
@ -59,9 +66,12 @@ extension CardDeckContainerView: View {
.edgesIgnoringSafeArea(.all)
.sheet(isPresented: self.$isShowingEditView, onDismiss: self.viewModel.resumeRound) {
EditDeckView(
viewModel: .init(currentDeck: self.viewModel.cards)
viewModel: .init(currentDeck: self.viewModel.cardDeck)
)
}
.onAppear {
self.viewModel.isTimerActive = true
}
}
}
@ -104,15 +114,6 @@ private extension CardDeckContainerView {
// func countdownFinished() {
// viewModel.pauseRound()
// }
func record(_ answerState: Card.AnswerState, forCardAt index: Int) {
viewModel.cards[index].answerState = answerState
if viewModel.isDeckEmpty {
viewModel.pauseRound()
}
}
}
@ -121,7 +122,9 @@ private extension CardDeckContainerView {
struct CardDeckContainerView_Previews: PreviewProvider {
static var previews: some View {
CardDeckContainerView()
CardDeckContainerView(
viewModel: .init(cardDeck: PreviewData.CardDecks.default)
)
.environment(\.managedObjectContext, CurrentApp.coreDataManager.mainContext)
// .previewLayout(PreviewLayout.iPhone11Landscape)
}

View File

@ -20,13 +20,13 @@ extension EditDeckView {
// MARK: - Published Outputs
@Published var currentDeck: [Card]
@Published var currentDeck: CardDeck
@Published var canAddNewCard: Bool = false
// MARK: - Init
init(
currentDeck: [Card] = [],
currentDeck: CardDeck,
newCard: Card = Card(context: CurrentApp.coreDataManager.backgroundContext)
) {
print("EditDeckView+ViewModel || init")
@ -67,7 +67,7 @@ extension EditDeckView.ViewModel {
newCardPromptTextPublisher,
newCardAnswerTextPublisher
)
.print("canAddNewCardPublisher")
// .print("canAddNewCardPublisher")
.map { !$0.0.isEmpty && !$0.1.isEmpty }
.eraseToAnyPublisher()
}
@ -76,6 +76,7 @@ extension EditDeckView.ViewModel {
// MARK: - Computeds
extension EditDeckView.ViewModel {
var cards: [Card] { currentDeck.cardsArray }
}
@ -83,17 +84,23 @@ extension EditDeckView.ViewModel {
extension EditDeckView.ViewModel {
func addNewCard() {
currentDeck.append(newCard)
// TODO: Persist changes here.
guard let managedObjectContext = currentDeck.managedObjectContext else { fatalError() }
currentDeck.addToCards(newCard)
CurrentApp.coreDataManager.save(managedObjectContext)
newCard = makeNewCard()
}
func removeCards(at offsets: IndexSet) {
currentDeck.remove(atOffsets: offsets)
// TODO: Persist changes here.
guard let managedObjectContext = currentDeck.managedObjectContext else { fatalError() }
for offset in offsets {
currentDeck.removeFromCards(cards[offset])
}
CurrentApp.coreDataManager.save(managedObjectContext)
}
}

View File

@ -36,7 +36,7 @@ extension EditDeckView: View {
Section(header: Text("Current Cards")) {
ForEach(viewModel.currentDeck) { card in
ForEach(viewModel.cards) { card in
VStack(alignment: .leading) {
Text(card.prompt ?? "")
.font(.headline)
@ -87,7 +87,7 @@ struct EditDeckView_Previews: PreviewProvider {
EditDeckView(
viewModel: .init(
currentDeck: [PreviewData.Cards.card1, PreviewData.Cards.card2]
currentDeck: PreviewData.CardDecks.default
)
)
.environment(\.managedObjectContext, CurrentApp.coreDataManager.mainContext)

View File

@ -10,6 +10,7 @@ import SwiftUI
struct RootView {
@FetchRequest(sortDescriptors: [], animation: nil) var cardDecks: FetchedResults<CardDeck>
}
@ -17,7 +18,11 @@ struct RootView {
extension RootView: View {
var body: some View {
CardDeckContainerView()
// 📝 In a production app, we'd want to make sure that the user's
// decks were properly fetched here.
CardDeckContainerView(
viewModel: .init(cardDeck: cardDecks.first!)
)
}
}