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:
parent
de76a64165
commit
4cd7256a60
|
@ -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 */,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -14,32 +14,35 @@ extension PreviewData {
|
|||
|
||||
enum Cards {
|
||||
static let card1: Card = {
|
||||
let card = Card(context: CurrentApp.coreDataManager.mainContext)
|
||||
|
||||
card.prompt = "What is another word for taxation?"
|
||||
card.answer = "Theft"
|
||||
|
||||
return card
|
||||
let card = Card(context: CurrentApp.coreDataManager.mainContext)
|
||||
|
||||
card.prompt = "What is another word for taxation?"
|
||||
card.answer = "Theft"
|
||||
card.answerState = .unanswered
|
||||
|
||||
return card
|
||||
}()
|
||||
|
||||
|
||||
static let card2: Card = {
|
||||
let card = Card(context: CurrentApp.coreDataManager.mainContext)
|
||||
|
||||
card.prompt = "What is the name of our closest galaxy?"
|
||||
card.answer = "Andromeda"
|
||||
|
||||
return card
|
||||
let card = Card(context: CurrentApp.coreDataManager.mainContext)
|
||||
|
||||
card.prompt = "What is the name of our closest galaxy?"
|
||||
card.answer = "Andromeda"
|
||||
card.answerState = .unanswered
|
||||
|
||||
return card
|
||||
}()
|
||||
|
||||
|
||||
static let card3: Card = {
|
||||
let card = Card(context: CurrentApp.coreDataManager.mainContext)
|
||||
|
||||
card.prompt = "Who invented calculus?"
|
||||
card.answer = "Issac Netwon"
|
||||
|
||||
return card
|
||||
let card = Card(context: CurrentApp.coreDataManager.mainContext)
|
||||
|
||||
card.prompt = "Who invented calculus?"
|
||||
card.answer = "Issac Netwon"
|
||||
card.answerState = .unanswered
|
||||
|
||||
return card
|
||||
}()
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -22,6 +22,7 @@ extension CardView {
|
|||
@Published var cardPromptText: String = ""
|
||||
@Published var cardAnswerText: String = ""
|
||||
|
||||
|
||||
// MARK: - Init
|
||||
init(card: Card) {
|
||||
self.card = card
|
||||
|
|
|
@ -13,6 +13,7 @@ import CypherPoetSwiftUIAnimationKit
|
|||
|
||||
|
||||
struct CardView {
|
||||
@Environment(\.accessibilityEnabled) private var isAccessibilityEnabled
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
|
||||
var cornerRadius: CGFloat = 12.0
|
||||
|
@ -61,15 +62,22 @@ extension CardView {
|
|||
}
|
||||
|
||||
private var cardContent: some View {
|
||||
VStack {
|
||||
Text(viewModel.cardPromptText)
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(Color("Accent2"))
|
||||
|
||||
if isShowingAnswer {
|
||||
Text(viewModel.cardAnswerText)
|
||||
.font(.title)
|
||||
.foregroundColor(Color("Accent1"))
|
||||
Group {
|
||||
if isAccessibilityEnabled {
|
||||
Text(isShowingAnswer ? viewModel.cardAnswerText : viewModel.cardPromptText)
|
||||
.font(.largeTitle)
|
||||
} else {
|
||||
VStack {
|
||||
Text(viewModel.cardPromptText)
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(Color("Accent2"))
|
||||
|
||||
if isShowingAnswer {
|
||||
Text(viewModel.cardAnswerText)
|
||||
.font(.title)
|
||||
.foregroundColor(Color("Accent1"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
|
|
|
@ -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)
|
||||
|
@ -126,7 +130,7 @@ struct DraggableCardView_Previews: PreviewProvider {
|
|||
card: PreviewData.Cards.default,
|
||||
distanceToDragForRemoval: 100
|
||||
)
|
||||
.environment(\.managedObjectContext, CurrentApp.coreDataManager.mainContext)
|
||||
.previewLayout(PreviewLayout.iPhone11Landscape)
|
||||
.environment(\.managedObjectContext, CurrentApp.coreDataManager.mainContext)
|
||||
.previewLayout(PreviewLayout.iPhone11Landscape)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
}
|
|
@ -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
|
||||
|
||||
|
||||
|
@ -60,7 +59,7 @@ extension CardDeckContainerView {
|
|||
|
||||
// MARK: - Publishers
|
||||
extension CardDeckContainerView.ViewModel {
|
||||
|
||||
|
||||
private var roundTickPublisher: Publishers.Share<AnyPublisher<Date, Never>> {
|
||||
Timer.publish(every: 1.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -127,7 +132,7 @@ private extension CardDeckContainerView.ViewModel {
|
|||
.map { _ in false }
|
||||
.assign(to: \.isTimerActive, on: self)
|
||||
.store(in: &subscriptions)
|
||||
|
||||
|
||||
CurrentApp.notificationCenter
|
||||
.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.map { _ in self.isDeckEmpty == false }
|
||||
|
|
|
@ -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 {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Computeds
|
||||
extension CardDeckView {
|
||||
|
||||
private var swipeDirectionIndicators: some View {
|
||||
HStack {
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue