Add functionality for editing a deck of cards

- TODO: Refactor data model so that `CardDeck` is an entity that holds `Cards`.
This commit is contained in:
CypherPoet 2020-01-21 16:31:57 -06:00
parent de76a64165
commit 4cd7256a60
16 changed files with 475 additions and 80 deletions

View File

@ -34,6 +34,11 @@
F378AA0E23D4CC6400296A76 /* DraggableCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA0D23D4CC6400296A76 /* DraggableCardView.swift */; };
F378AA1023D5BAD500296A76 /* CardDeckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA0F23D5BAD500296A76 /* CardDeckView.swift */; };
F378AA1323D63AA700296A76 /* NumberFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA1223D63AA700296A76 /* NumberFormatters.swift */; };
F378AA1523D7156200296A76 /* Card+Outcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA1423D7156100296A76 /* Card+Outcome.swift */; };
F378AA1723D733EF00296A76 /* EditDeckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F378AA1623D733EE00296A76 /* EditDeckView.swift */; };
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 */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -63,6 +68,11 @@
F378AA0D23D4CC6400296A76 /* DraggableCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableCardView.swift; sourceTree = "<group>"; };
F378AA0F23D5BAD500296A76 /* CardDeckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardDeckView.swift; sourceTree = "<group>"; };
F378AA1223D63AA700296A76 /* NumberFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatters.swift; sourceTree = "<group>"; };
F378AA1423D7156100296A76 /* Card+Outcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Card+Outcome.swift"; sourceTree = "<group>"; };
F378AA1623D733EE00296A76 /* EditDeckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditDeckView.swift; sourceTree = "<group>"; };
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -197,6 +207,7 @@
isa = PBXGroup;
children = (
F378A9F423D44E3800296A76 /* Card+CoreDataClass.swift */,
F378AA1423D7156100296A76 /* Card+Outcome.swift */,
F378A9F523D44E3900296A76 /* Card+CoreDataProperties.swift */,
);
path = Card;
@ -205,6 +216,7 @@
F378A9F823D47DC200296A76 /* Views */ = {
isa = PBXGroup;
children = (
F378AA1C23D7A07500296A76 /* Countdown Timer */,
F378A9F923D47DCE00296A76 /* Card */,
);
path = Views;
@ -224,6 +236,8 @@
isa = PBXGroup;
children = (
F378AA0123D48CB900296A76 /* CardDeckContainerView.swift */,
F378AA1623D733EE00296A76 /* EditDeckView.swift */,
F378AA1823D7359B00296A76 /* EditDeckView+ViewModel.swift */,
F378AA0F23D5BAD500296A76 /* CardDeckView.swift */,
F378AA0323D48D6D00296A76 /* CardDeckContainerView+ViewModel.swift */,
);
@ -255,6 +269,15 @@
path = Formatters;
sourceTree = "<group>";
};
F378AA1C23D7A07500296A76 /* Countdown Timer */ = {
isa = PBXGroup;
children = (
F378AA1A23D7A06500296A76 /* CountdownTimerView.swift */,
F378AA1D23D7A0B900296A76 /* CountdownTimerView+ViewModel.swift */,
);
path = "Countdown Timer";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -338,6 +361,7 @@
buildActionMask = 2147483647;
files = (
F378AA0423D48D6D00296A76 /* CardDeckContainerView+ViewModel.swift in Sources */,
F378AA1E23D7A0B900296A76 /* CountdownTimerView+ViewModel.swift in Sources */,
F378A9F623D44E3900296A76 /* Card+CoreDataClass.swift in Sources */,
F378AA0C23D4A5D300296A76 /* View+Stacked.swift in Sources */,
F378A9F723D44E3900296A76 /* Card+CoreDataProperties.swift in Sources */,
@ -345,6 +369,7 @@
F378A9FD23D47E1000296A76 /* CardView+ViewModel.swift in Sources */,
F378A9E423D43A1700296A76 /* PreviewData.swift in Sources */,
F378A9DA23D4393100296A76 /* RootView.swift in Sources */,
F378AA1B23D7A06600296A76 /* CountdownTimerView.swift in Sources */,
F378AA0B23D4A5D300296A76 /* StackedViewModifier.swift in Sources */,
F378AA1323D63AA700296A76 /* NumberFormatters.swift in Sources */,
F378A9C423D438AE00296A76 /* Flashzilla.xcdatamodeld in Sources */,
@ -352,6 +377,9 @@
F378A9BF23D438AE00296A76 /* AppDelegate.swift in Sources */,
F378A9E623D43A6900296A76 /* CoreDataManager+Utils.swift in Sources */,
F378AA1023D5BAD500296A76 /* CardDeckView.swift in Sources */,
F378AA1723D733EF00296A76 /* EditDeckView.swift in Sources */,
F378AA1523D7156200296A76 /* Card+Outcome.swift in Sources */,
F378AA1923D7359B00296A76 /* EditDeckView+ViewModel.swift in Sources */,
F378A9F323D43CAA00296A76 /* PreviewData+Cards.swift in Sources */,
F378AA0223D48CB900296A76 /* CardDeckContainerView.swift in Sources */,
F378A9FB23D47DD900296A76 /* CardView.swift in Sources */,

View File

@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/CypherPoet/CypherPoetCoreDataKit.git",
"state": {
"branch": null,
"revision": "e14a3abf3e679d41c9e0fcc55d3e4d7ccd8ce1b6",
"version": "0.0.6"
"revision": "dc317bf98ab5674f4c40ddcdbcb882738d4f8ac3",
"version": "0.0.7"
}
},
{

View File

@ -20,4 +20,11 @@ extension Card {
@NSManaged public var prompt: String?
@NSManaged public var answer: String?
@NSManaged public var answerStateValue: Int16
var answerState: Card.AnswerState {
get { Card.AnswerState(rawValue: answerStateValue)! }
set { answerStateValue = newValue.rawValue }
}
}

View File

@ -0,0 +1,19 @@
//
// Card+Outcome.swift
// Flashzilla
//
// Created by CypherPoet on 1/21/20.
//
//
import Foundation
extension Card {
enum AnswerState: Int16 {
case unanswered
case correct
case incorrect
}
}

View File

@ -2,6 +2,7 @@
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="15702" systemVersion="19C57" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Card" representedClassName="Card" syncable="YES">
<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"/>
<uniquenessConstraints>
<uniquenessConstraint>
@ -11,6 +12,6 @@
</uniquenessConstraints>
</entity>
<elements>
<element name="Card" positionX="-63" positionY="-18" width="128" height="73"/>
<element name="Card" positionX="-63" positionY="-18" width="128" height="88"/>
</elements>
</model>

View File

@ -18,6 +18,7 @@ extension PreviewData {
card.prompt = "What is another word for taxation?"
card.answer = "Theft"
card.answerState = .unanswered
return card
}()
@ -28,6 +29,7 @@ extension PreviewData {
card.prompt = "What is the name of our closest galaxy?"
card.answer = "Andromeda"
card.answerState = .unanswered
return card
}()
@ -38,6 +40,7 @@ extension PreviewData {
card.prompt = "Who invented calculus?"
card.answer = "Issac Netwon"
card.answerState = .unanswered
return card
}()

View File

@ -40,11 +40,11 @@ extension Stacked {
}
}
// MARK: - Preview
struct Stacked_Previews: PreviewProvider {
static var previews: some View {
EmptyView()
}
}
//
//// MARK: - Preview
//struct Stacked_Previews: PreviewProvider {
//
// static var previews: some View {
// EmptyView()
// }
//}

View File

@ -22,6 +22,7 @@ extension CardView {
@Published var cardPromptText: String = ""
@Published var cardAnswerText: String = ""
// MARK: - Init
init(card: Card) {
self.card = card

View File

@ -13,6 +13,7 @@ import CypherPoetSwiftUIAnimationKit
struct CardView {
@Environment(\.accessibilityEnabled) private var isAccessibilityEnabled
@ObservedObject var viewModel: ViewModel
var cornerRadius: CGFloat = 12.0
@ -61,6 +62,11 @@ extension CardView {
}
private var cardContent: some View {
Group {
if isAccessibilityEnabled {
Text(isShowingAnswer ? viewModel.cardAnswerText : viewModel.cardPromptText)
.font(.largeTitle)
} else {
VStack {
Text(viewModel.cardPromptText)
.font(.largeTitle)
@ -72,6 +78,8 @@ extension CardView {
.foregroundColor(Color("Accent1"))
}
}
}
}
.padding(20)
.multilineTextAlignment(.center)
}

View File

@ -19,7 +19,7 @@ struct DraggableCardView {
var horizontalSensitivity: CGFloat = 1.0
var verticalSensitivity: CGFloat = 0.0
var onRemove: ((Card) -> Void)? = nil
var onRemove: ((Card, Card.AnswerState) -> Void)? = nil
@GestureState private var dragOffset = CGSize.zero
let feedbackGenerator = UINotificationFeedbackGenerator()
@ -44,8 +44,10 @@ extension DraggableCardView: View {
)
.animation(.easeIn(duration: 0.25))
)
.animation(nil)
.rotationEffect(self.cardRotation)
.offset(self.cardOffset)
.animation(.spring())
.gesture(self.dragGesture)
}
}
@ -102,7 +104,9 @@ extension DraggableCardView {
})
.onEnded { value in
if abs(value.translation.width) > self.distanceToDragForRemoval {
self.onRemove?(self.card)
let answerState: Card.AnswerState = value.translation.width > 0 ? .correct : .incorrect
self.onRemove?(self.card, answerState)
self.feedbackGenerator.notificationOccurred(.success)
} else {
self.feedbackGenerator.notificationOccurred(.error)

View File

@ -0,0 +1,43 @@
//
// CountdownTimerView+ViewModel.swift
// Flashzilla
//
// Created by CypherPoet on 1/21/20.
//
//
import Foundation
extension CountdownTimerView {
final class ViewModel {
var timeRemaining: TimeInterval
// MARK: - Init
init(
timeRemaining: TimeInterval = 100.0
) {
self.timeRemaining = timeRemaining
}
}
}
// MARK: - Publishers
extension CountdownTimerView.ViewModel {
}
// MARK: - Computeds
extension CountdownTimerView.ViewModel {
var timeRemainingText: String {
NumberFormatters.cardCountdown.string(for: timeRemaining) ?? ""
}
}
// MARK: - Private Helpers
private extension CountdownTimerView.ViewModel {
}

View File

@ -16,9 +16,7 @@ extension CardDeckContainerView {
final class ViewModel: NSObject, FetchedResultsControlling, ObservableObject {
typealias FetchedResult = Card
private var subscriptions = Set<AnyCancellable>()
private var isTimerActive = true
internal lazy var fetchRequest: NSFetchRequest<Card> = {
@ -33,6 +31,7 @@ extension CardDeckContainerView {
internal lazy var fetchedResultsController: FetchedResultsController = makeFetchedResultsController()
var isTimerActive = true
var roundDuration: TimeInterval
@ -74,6 +73,7 @@ extension CardDeckContainerView.ViewModel {
.map { _ in
self.isTimerActive ? max(0, self.timeRemaining - 1.0) : self.timeRemaining
}
.removeDuplicates()
.eraseToAnyPublisher()
}
}
@ -81,11 +81,12 @@ extension CardDeckContainerView.ViewModel {
// MARK: - Computeds
extension CardDeckContainerView.ViewModel {
var timeRemainingText: String {
NumberFormatters.cardCountdown.string(for: timeRemaining) ?? ""
var visibleCards: [Card] {
cards.filter { $0.answerState == .unanswered }
}
var isDeckEmpty: Bool { cards.isEmpty }
var isDeckEmpty: Bool { visibleCards.isEmpty }
}
@ -100,8 +101,8 @@ extension CardDeckContainerView.ViewModel {
func resetDeck() {
fetchCards()
self.timeRemaining = roundDuration
cards.forEach { $0.answerState = .unanswered }
self.isTimerActive = true
}
@ -109,6 +110,10 @@ extension CardDeckContainerView.ViewModel {
func pauseRound() {
isTimerActive = false
}
func resumeRound() {
isTimerActive = true
}
}

View File

@ -12,6 +12,8 @@ import CypherPoetSwiftUIKit
struct CardDeckContainerView {
@ObservedObject var viewModel: ViewModel = .init()
@State private var isShowingEditView = false
}
@ -22,32 +24,30 @@ extension CardDeckContainerView: View {
GeometryReader { geometry in
ZStack {
VStack(spacing: 32) {
Text("Time Remaining: ")
.font(.title)
.foregroundColor(Color("Accent3"))
+ Text(self.viewModel.timeRemainingText)
.font(.title)
.fontWeight(.bold)
.foregroundColor(Color("Accent3"))
CountdownTimerView(
viewModel: .init(timeRemaining: self.viewModel.timeRemaining)
)
CardDeckView(
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.cards,
onRemove: { (card, index) in self.cardRemoved(at: index) }
cards: self.viewModel.visibleCards,
cardAnswered: { (answerState, index) in self.record(answerState, forCardAt: index) }
)
.allowsHitTesting(self.viewModel.timeRemaining > 0)
}
if self.viewModel.isDeckEmpty {
VStack {
Spacer()
HStack {
Spacer()
VStack {
Spacer()
if self.viewModel.isDeckEmpty {
self.resetButton
} else {
self.editDeckButton
}
}
}
@ -57,6 +57,11 @@ extension CardDeckContainerView: View {
.padding()
.background(Color("CardDeckBackground"))
.edgesIgnoringSafeArea(.all)
.sheet(isPresented: self.$isShowingEditView, onDismiss: self.viewModel.resumeRound) {
EditDeckView(
viewModel: .init(currentDeck: self.viewModel.cards)
)
}
}
}
@ -69,6 +74,20 @@ extension CardDeckContainerView {
// MARK: - View Variables
extension CardDeckContainerView {
private var editDeckButton: some View {
Button(action: {
self.viewModel.pauseRound()
self.isShowingEditView = true
}) {
Image(systemName: "pencil")
.padding()
.background(Color("Accent1"))
.clipShape(Capsule())
.foregroundColor(.primary)
}
}
private var resetButton: some View {
Button("Start Again", action: viewModel.resetDeck)
.padding()
@ -79,12 +98,16 @@ extension CardDeckContainerView {
}
// MARK: - Private Helpers
private extension CardDeckContainerView {
func cardRemoved(at index: Int) {
viewModel.cards.remove(at: index)
// func countdownFinished() {
// viewModel.pauseRound()
// }
func record(_ answerState: Card.AnswerState, forCardAt index: Int) {
viewModel.cards[index].answerState = answerState
if viewModel.isDeckEmpty {
viewModel.pauseRound()

View File

@ -11,15 +11,17 @@ import SwiftUI
struct CardDeckView {
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor
@Environment(\.accessibilityEnabled) var isAccessibilityEnabled
var width: CGFloat
var height: CGFloat
var cards: [Card]
var onRemove: ((Card, Int) -> Void)?
var cardAnswered: ((Card.AnswerState, Int) -> Void)?
}
// MARK: - View
extension CardDeckView: View {
var body: some View {
@ -31,39 +33,79 @@ extension CardDeckView: View {
card: card,
distanceToDragForRemoval: self.width / 2,
horizontalSensitivity: 1.0,
onRemove: { _ in self.onRemove?(card, index) }
onRemove: { (_, answerState) in self.cardAnswered?(answerState, index) }
)
.stacked(at: index + 1, outOf: deckSize, offsetMultiple: CGFloat(30 / deckSize))
.allowsHitTesting(index == deckSize - 1)
.accessibility(hidden: index < deckSize - 1)
.accessibility(addTraits: .isButton)
}
if self.differentiateWithoutColor {
if isShowingDeckControls {
VStack {
Spacer()
self.swipeDirectionIndicators
self.deckControls
}
}
}
.frame(width: width, height: height)
}
}
private var swipeDirectionIndicators: some View {
HStack {
// MARK: - Computeds
extension CardDeckView {
var isShowingDeckControls: Bool {
differentiateWithoutColor &&
isAccessibilityEnabled &&
!cards.isEmpty
}
}
// MARK: - View Variables
extension CardDeckView {
private var markIncorrectButton: some View {
Button(action: {
self.cardAnswered?(.incorrect, self.cards.count - 1)
}) {
Image(systemName: "xmark.circle")
.padding()
.background(Color.black.opacity(0.7))
.clipShape(Circle())
Spacer()
}
.accessibility(label: Text("Wrong Answer"))
.accessibility(hint: Text("Mark your answer as being incorrect."))
}
private var markCorrectButton: some View {
Button(action: {
withAnimation {
self.cardAnswered?(.correct, self.cards.count - 1)
}
}) {
Image(systemName: "checkmark.circle")
.padding()
.background(Color.black.opacity(0.7))
.clipShape(Circle())
}
.accessibility(label: Text("Correct Answer"))
.accessibility(hint: Text("Mark your answer as being correct."))
}
private var deckControls: some View {
HStack {
markIncorrectButton
Spacer()
markCorrectButton
}
.foregroundColor(.white)
.font(.largeTitle)
}

View File

@ -0,0 +1,116 @@
//
// EditDeckView+ViewModel.swift
// Flashzilla
//
// Created by CypherPoet on 1/21/20.
//
//
import SwiftUI
import Combine
import CoreData
extension EditDeckView {
final class ViewModel: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
@ObservedObject var newCard: Card
// MARK: - Published Outputs
@Published var currentDeck: [Card]
@Published var canAddNewCard: Bool = false
// MARK: - Init
init(
currentDeck: [Card] = [],
newCard: Card = Card(context: CurrentApp.coreDataManager.backgroundContext)
) {
print("EditDeckView+ViewModel || init")
self.currentDeck = currentDeck
self.newCard = newCard
setupSubscribers()
}
}
}
// MARK: - Publishers
extension EditDeckView.ViewModel {
private var newCardPromptTextPublisher: Publishers.Share<AnyPublisher<String, Never>> {
newCard.publisher(for: \.prompt)
.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
.removeDuplicates()
.compactMap { $0 }
.eraseToAnyPublisher()
.share()
}
private var newCardAnswerTextPublisher: Publishers.Share<AnyPublisher<String, Never>> {
newCard.publisher(for: \.answer)
.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
.removeDuplicates()
.compactMap { $0 }
.eraseToAnyPublisher()
.share()
}
private var canAddNewCardPublisher: AnyPublisher<Bool, Never> {
Publishers.CombineLatest(
newCardPromptTextPublisher,
newCardAnswerTextPublisher
)
.print("canAddNewCardPublisher")
.map { !$0.0.isEmpty && !$0.1.isEmpty }
.eraseToAnyPublisher()
}
}
// MARK: - Computeds
extension EditDeckView.ViewModel {
}
// MARK: - Public Methods
extension EditDeckView.ViewModel {
func addNewCard() {
currentDeck.append(newCard)
// TODO: Persist changes here.
newCard = makeNewCard()
}
func removeCards(at offsets: IndexSet) {
currentDeck.remove(atOffsets: offsets)
// TODO: Persist changes here.
}
}
// MARK: - Private Helpers
private extension EditDeckView.ViewModel {
func makeNewCard() -> Card {
Card(context: CurrentApp.coreDataManager.backgroundContext)
}
func setupSubscribers() {
canAddNewCardPublisher
.receive(on: DispatchQueue.main)
.assign(to: \.canAddNewCard, on: self)
.store(in: &subscriptions)
}
}

View File

@ -0,0 +1,95 @@
//
// EditDeckView.swift
// Flashzilla
//
// Created by CypherPoet on 1/21/20.
//
//
import SwiftUI
import CypherPoetCoreDataKit_BindingUtils
struct EditDeckView {
@Environment(\.presentationMode) private var presentationMode
@ObservedObject var viewModel: ViewModel
}
// MARK: - View
extension EditDeckView: View {
var body: some View {
NavigationView {
List {
Section(header: Text("New Card").font(.headline)) {
TextField("Prompt", text: Binding($viewModel.newCard.prompt, replacingNilWith: ""))
TextField("Answer", text: Binding($viewModel.newCard.answer, replacingNilWith: ""))
Button(action: viewModel.addNewCard) {
Text("Add New Card")
}
.disabled(!viewModel.canAddNewCard)
.foregroundColor(.accentColor)
}
Section(header: Text("Current Cards")) {
ForEach(viewModel.currentDeck) { card in
VStack(alignment: .leading) {
Text(card.prompt ?? "")
.font(.headline)
Text(card.answer ?? "")
.font(.subheadline)
}
}
.onDelete(perform: viewModel.removeCards(at:))
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Edit Cards")
.navigationBarItems(trailing: doneButton)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
// MARK: - Computeds
extension EditDeckView {
}
// MARK: - View Variables
extension EditDeckView {
private var doneButton: some View {
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Done")
}
}
}
// MARK: - Private Helpers
private extension EditDeckView {
}
// MARK: - Preview
struct EditDeckView_Previews: PreviewProvider {
static var previews: some View {
EditDeckView(
viewModel: .init(
currentDeck: [PreviewData.Cards.card1, PreviewData.Cards.card2]
)
)
.environment(\.managedObjectContext, CurrentApp.coreDataManager.mainContext)
}
}