diff --git a/Spellbook.xcodeproj/project.pbxproj b/Spellbook.xcodeproj/project.pbxproj index 4aef47d..6cd84cd 100644 --- a/Spellbook.xcodeproj/project.pbxproj +++ b/Spellbook.xcodeproj/project.pbxproj @@ -138,6 +138,8 @@ 8ED0B9382431392700A085F4 /* NameDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ED0B9372431392700A085F4 /* NameDisplayable.swift */; }; 8EEA28FD297C23D8004971AD /* SpellFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EEA28FC297C23D8004971AD /* SpellFilter.swift */; }; 8EEAFCCA2645FB4C00A05D01 /* LegacyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EEAFCC92645FB4C00A05D01 /* LegacyProfileTests.swift */; }; + 8EEDC3E22ADA69170045D002 /* HigherLevelSlotController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EEDC3E12ADA69170045D002 /* HigherLevelSlotController.swift */; }; + 8EEDC3E42AE739A60045D002 /* ConfirmNextAvailableCastController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EEDC3E32AE739A50045D002 /* ConfirmNextAvailableCastController.swift */; }; 8EF6EF0B264668F60059BCE8 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EF6EF0A264668F60059BCE8 /* Version.swift */; }; 8EF6EF0D26485A2E0059BCE8 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EF6EF0C26485A2E0059BCE8 /* VersionTests.swift */; }; /* End PBXBuildFile section */ @@ -295,6 +297,8 @@ 8ED0B9372431392700A085F4 /* NameDisplayable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameDisplayable.swift; sourceTree = ""; }; 8EEA28FC297C23D8004971AD /* SpellFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellFilter.swift; sourceTree = ""; }; 8EEAFCC92645FB4C00A05D01 /* LegacyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyProfileTests.swift; sourceTree = ""; }; + 8EEDC3E12ADA69170045D002 /* HigherLevelSlotController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HigherLevelSlotController.swift; sourceTree = ""; }; + 8EEDC3E32AE739A50045D002 /* ConfirmNextAvailableCastController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmNextAvailableCastController.swift; sourceTree = ""; }; 8EF6EF0A264668F60059BCE8 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; 8EF6EF0C26485A2E0059BCE8 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -359,6 +363,7 @@ isa = PBXGroup; children = ( 397EE96C21ACA86400B116B0 /* Main.storyboard */, + 8EEDC3E12ADA69170045D002 /* HigherLevelSlotController.swift */, 8E07AE0C2A0F708700C0487B /* ImportCharacterController.swift */, 8EB3692129405EAB00856738 /* SpellbookReducers.swift */, 8EB3691D293F123900856738 /* AppReducer.swift */, @@ -384,6 +389,7 @@ 8E6148D7241991C100842A18 /* Constants.swift */, 8E6148C3241991C000842A18 /* Controllers.swift */, 8E6148A5241991BF00842A18 /* DeletionPromptController.swift */, + 8EEDC3E32AE739A50045D002 /* ConfirmNextAvailableCastController.swift */, 8E61489E241991BE00842A18 /* Duration.swift */, 8E6148DE241991C100842A18 /* EnumMap.swift */, 8E6148C5241991C000842A18 /* EnumUtilities.swift */, @@ -772,9 +778,11 @@ 8E6E2B8228E9FE4A00AA6483 /* SpellSlotsController.swift in Sources */, 8EB3691E293F123900856738 /* AppReducer.swift in Sources */, 8E6148F8241991C200842A18 /* StringUtils.swift in Sources */, + 8EEDC3E22ADA69170045D002 /* HigherLevelSlotController.swift in Sources */, 8E614927241991C200842A18 /* SpellBuilder.swift in Sources */, 8E02F76224345F6200A3050C /* YesNo.swift in Sources */, 8E07AE0D2A0F708700C0487B /* ImportCharacterController.swift in Sources */, + 8EEDC3E42AE739A60045D002 /* ConfirmNextAvailableCastController.swift in Sources */, 8EEA28FD297C23D8004971AD /* SpellFilter.swift in Sources */, 8E614916241991C200842A18 /* SideMenuController.swift in Sources */, 8E74FA5A29EBD7D900E13B91 /* SpellSlotsManagerCell.swift in Sources */, diff --git a/Spellbook/AppDelegate.swift b/Spellbook/AppDelegate.swift index 4424842..f9fbc1a 100644 --- a/Spellbook/AppDelegate.swift +++ b/Spellbook/AppDelegate.swift @@ -10,7 +10,7 @@ import UIKit import ReSwift typealias SpellbookStore = Store -let store = SpellbookStore(reducer: appReducer, state: nil, middleware: [switchProfileMiddleware, switchProfileByNameMiddleware, createProfileMiddleware, saveProfileMiddleware, saveCurrentProfileMiddleware, deleteProfileMiddleware, deleteProfileByNameMiddleware]) +let store = SpellbookStore(reducer: appReducer, state: nil, middleware: [switchProfileMiddleware, switchProfileByNameMiddleware, createProfileMiddleware, saveProfileMiddleware, saveCurrentProfileMiddleware, deleteProfileMiddleware, deleteProfileByNameMiddleware, makeToastMiddleware]) @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { diff --git a/Spellbook/AppReducer.swift b/Spellbook/AppReducer.swift index b36b315..9031b1f 100644 --- a/Spellbook/AppReducer.swift +++ b/Spellbook/AppReducer.swift @@ -168,6 +168,9 @@ func specificActionReducer(action: Action, state: inout SpellbookAppState) -> Sp case let action as SaveSettingsAction: return saveSettingsReducer(action: action, state: &state) + case let action as CastSpellAction: + return castSpellReducer(action: action, state: state) + // If we somehow get here, just do nothing default: return state diff --git a/Spellbook/Base.lproj/Main.storyboard b/Spellbook/Base.lproj/Main.storyboard index d2cd761..34b5c2a 100644 --- a/Spellbook/Base.lproj/Main.storyboard +++ b/Spellbook/Base.lproj/Main.storyboard @@ -16,6 +16,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -394,76 +463,76 @@ - + - @@ -512,12 +599,15 @@ + + - - + + + @@ -531,18 +621,19 @@ - + - + + - + @@ -580,6 +671,7 @@ + @@ -758,6 +850,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -844,9 +1024,13 @@ + + + + @@ -958,6 +1142,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1253,7 +1524,7 @@ - + @@ -1324,14 +1595,14 @@ - + - + @@ -1406,7 +1677,7 @@ @@ -1691,7 +1962,7 @@ - + @@ -1749,7 +2020,7 @@ - + @@ -1839,7 +2110,7 @@ - + @@ -1898,7 +2169,7 @@ - + @@ -1985,7 +2256,7 @@ - + @@ -2136,7 +2407,7 @@ - + @@ -2258,7 +2529,7 @@ - + @@ -2372,7 +2643,7 @@ - + @@ -2482,7 +2753,7 @@ - + @@ -2626,7 +2897,7 @@ - + @@ -2765,7 +3036,7 @@ - + @@ -2972,5 +3243,11 @@ + + + + + + diff --git a/Spellbook/ConfirmNextAvailableCastController.swift b/Spellbook/ConfirmNextAvailableCastController.swift new file mode 100644 index 0000000..f0ed759 --- /dev/null +++ b/Spellbook/ConfirmNextAvailableCastController.swift @@ -0,0 +1,60 @@ +// +// ConfirmNextAvailableCastController.swift +// Spellbook +// +// Created by Mac Pro on 10/23/23. +// Copyright © 2023 Jonathan Carifio. All rights reserved. +// + +import UIKit + +class ConfirmNextAvailableCastController: UIViewController { + + @IBOutlet weak var availableCastMessage: UILabel! + @IBOutlet weak var yesButton: UIButton! + @IBOutlet weak var noButton: UIButton! + + var spell: Spell? + var level: Int = 0 + + // TODO: What's a better way to do this? + var toastController: UIViewController? = nil + + override func viewDidLoad() { + super.viewDidLoad() + + // We should never be here without an assigned profile + let name = store.state.profile?.name ?? "" + let baseLevel = spell?.level ?? 0 + + availableCastMessage.text = "\(name) has no slots of level \(baseLevel) remaining. Would you like to use a slot of level \(level)?" + + noButton.addTarget(self, action: #selector(noButtonPressed), for: UIControl.Event.touchUpInside) + yesButton.addTarget(self, action: #selector(yesButtonPressed), for: UIControl.Event.touchUpInside) + + // Do any additional setup after loading the view. + } + + @objc func noButtonPressed() { + self.dismiss(animated: true, completion: nil) + } + + @objc func yesButtonPressed() { + self.dismiss(animated: true, completion: { () -> Void in + + if self.level == 0 { + return + } + if let spell = self.spell { + store.dispatch(CastSpellAction(level: self.level)) + + let message = "\(spell.name) was cast at level \(self.level)" + let controller = self.toastController ?? (self.parent ?? self) + Toast.makeToast(message, controller: controller) + } + }) + } + +} + + diff --git a/Spellbook/DeletionPromptController.swift b/Spellbook/DeletionPromptController.swift index c2f3e28..930232c 100644 --- a/Spellbook/DeletionPromptController.swift +++ b/Spellbook/DeletionPromptController.swift @@ -24,14 +24,12 @@ class DeletionPromptController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - - // Set the text + deleteMessage.text = "Are you sure you want to delete \(name ?? "")?" - + // Set the layout //setLayout() - - // Set the button functions + noButton.addTarget(self, action: #selector(noButtonPressed), for: UIControl.Event.touchUpInside) yesButton.addTarget(self, action: #selector(yesButtonPressed), for: UIControl.Event.touchUpInside) @@ -95,16 +93,5 @@ class DeletionPromptController: UIViewController { self.main!.openCharacterCreationDialog(mustComplete: creationNecessary) }) } - - - /* - // MARK: - Navigation - - // In a storyboard-based application, you will often want to do a little preparation before navigation - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - // Get the new view controller using segue.destination. - // Pass the selected object to the new view controller. - } - */ } diff --git a/Spellbook/HigherLevelSlotController.swift b/Spellbook/HigherLevelSlotController.swift new file mode 100644 index 0000000..ddc9d2b --- /dev/null +++ b/Spellbook/HigherLevelSlotController.swift @@ -0,0 +1,76 @@ +// +// HigherLevelSlotController.swift +// Spellbook +// +// Created by Mac Pro on 10/14/23. +// Copyright © 2023 Jonathan Carifio. All rights reserved. +// + +import UIKit + +import ReSwift + +class HigherLevelSlotController: UIViewController { + + @IBOutlet weak var slotLevelChooser: UITextField! + @IBOutlet weak var cancelButton: UIButton! + @IBOutlet weak var castButton: UIButton! + + var spell: Spell? + var textDelegate: TextFieldChooserDelegate? + + // TODO: What's a better way to do this? + var toastController: UIViewController? = nil + + override func viewDidLoad() { + super.viewDidLoad() + + cancelButton.addTarget(self, action: #selector(cancelButtonPressed), for: UIControl.Event.touchUpInside) + castButton.addTarget(self, action: #selector(castButtonPressed), for: UIControl.Event.touchUpInside) + + guard let spell = self.spell else { return } + guard let profile = store.state.profile else { return } + let status = profile.spellSlotStatus + let baseLevel = spell.level + let maxLevel = status.maxLevelWithSlots() + let range = baseLevel...maxLevel + + let initialLevel = status.minLevelWithCondition(condition: { level in + return status.hasAvailableSlots(level: level) && level >= baseLevel + }) + + // TODO: It's kind of gross to need to use this dummy type + // It feels like a refactor of the delegate is necessary + self.textDelegate = TextFieldChooserDelegate( + items: Array(range), + title: "Select Slot Level", + itemProvider: { () in return initialLevel }, + nameGetter: ordinal, + textSetter: ordinal, + nameConstructor: { valueFrom(ordinal: $0) ?? 0 }, + itemFilter: { level in return status.hasAvailableSlots(level: level) } + ) + + slotLevelChooser.text = ordinal(number: initialLevel) + slotLevelChooser.delegate = self.textDelegate! + } + + @objc func cancelButtonPressed() { + self.dismiss(animated: true, completion: nil) + } + + @objc func castButtonPressed() { + guard let spell = self.spell else { return } + if let text = slotLevelChooser.text { + if let level = valueFrom(ordinal: text) { + store.dispatch(CastSpellAction(level: level)) + let message = "\(spell.name) was cast at level \(level)" + let controller = self.toastController ?? (self.parent ?? self) + Toast.makeToast(message, controller: controller) + } + } + + self.dismiss(animated: true, completion: nil) + } + +} diff --git a/Spellbook/SpellFilter.swift b/Spellbook/SpellFilter.swift index 8ae0180..de9ae85 100644 --- a/Spellbook/SpellFilter.swift +++ b/Spellbook/SpellFilter.swift @@ -102,6 +102,9 @@ func filterSpell(spell: Spell, sortFilterStatus: SortFilterStatus, spellFilterSt toHide = toHide || (sortFilterStatus.preparedSelected() && !spellFilterStatus.isPrepared(spell)) toHide = toHide || !sortFilterStatus.getRitualFilter(spell.ritual) toHide = toHide || !sortFilterStatus.getConcentrationFilter(spell.concentration) + toHide = toHide || !sortFilterStatus.getVerbalFilter(spell.verbal) + toHide = toHide || !sortFilterStatus.getSomaticFilter(spell.somatic) + toHide = toHide || !sortFilterStatus.getMaterialFilter(spell.material) toHide = toHide || (isText && !spellName.contains(text)) return !toHide } diff --git a/Spellbook/SpellSlotStatus.swift b/Spellbook/SpellSlotStatus.swift index d7451fe..9ce985f 100644 --- a/Spellbook/SpellSlotStatus.swift +++ b/Spellbook/SpellSlotStatus.swift @@ -15,7 +15,7 @@ class SpellSlotStatus { private static let totalSlotsKey = "totalSlots" private static let usedSlotsKey = "usedSlotsKey" - + init(totalSlots: [Int], usedSlots: [Int]) { self.totalSlots = totalSlots self.usedSlots = usedSlots @@ -37,6 +37,9 @@ class SpellSlotStatus { func getUsedSlots(level: Int) -> Int { return usedSlots[level - 1] } func getAvailableSlots(level: Int) -> Int { return totalSlots[level - 1] - usedSlots[level - 1] } + func hasSlots(level: Int) -> Bool { return getTotalSlots(level: level) > 0 } + func hasAvailableSlots(level: Int) -> Bool { return getAvailableSlots(level: level) > 0 } + func setTotalSlots(level: Int, slots: Int) { totalSlots[level - 1] = slots if (slots < usedSlots[level - 1]) { @@ -48,37 +51,65 @@ class SpellSlotStatus { let used = totalSlots[level - 1] - slots usedSlots[level - 1] = max(0, used) } - + func setUsedSlots(level: Int, slots: Int) { usedSlots[level - 1] = min(slots, totalSlots[level - 1]) } - + func regainAllSlots() { usedSlots = usedSlots.map { _ in 0 } } - + func useSlot(level: Int) { usedSlots[level - 1] = min(usedSlots[level - 1] + 1, totalSlots[level - 1]) } - + func gainSlot(level: Int) { usedSlots[level - 1] = max(usedSlots[level - 1] - 1, 0) } - - func maxLevelWithSlots() -> Int { - for level in Spellbook.MAX_SPELL_LEVEL...1 { - if (self.getTotalSlots(level: level) > 0) { + + func levelWithCondition(condition: Predicate, range: SeqInt) -> Int where SeqInt.Iterator.Element == Int { + for level in range { + if condition(level) { return level } } return 0 } - + + func minLevelWithCondition(condition: Predicate) -> Int { + return levelWithCondition(condition: condition, range: 1...Spellbook.MAX_SPELL_LEVEL) + } + + func maxLevelWithCondition(condition: Predicate) -> Int { + return levelWithCondition(condition: condition, range: (1...Spellbook.MAX_SPELL_LEVEL).reversed()) + } + + func minLevelWithSlots() -> Int { + return minLevelWithCondition(condition: self.hasSlots) + } + + func maxLevelWithSlots() -> Int { + return maxLevelWithCondition(condition: self.hasSlots) + } + + func minLevelWithAvailableSlots() -> Int { + return minLevelWithCondition(condition: self.hasAvailableSlots) + } + + func maxLevelWithAvailableSlots() -> Int { + return maxLevelWithCondition(condition: self.hasAvailableSlots) + } + + func nextAvailableSlotLevel(baseLevel: Int) -> Int { + return levelWithCondition(condition: self.hasAvailableSlots, range: baseLevel...Spellbook.MAX_SPELL_LEVEL) + } + func toSION() -> SION { var sion: SION = [:] sion[SpellSlotStatus.totalSlotsKey].array = totalSlots.map { SION($0) } sion[SpellSlotStatus.usedSlotsKey].array = usedSlots.map { SION($0) } return sion } - + } diff --git a/Spellbook/SpellWindowController.swift b/Spellbook/SpellWindowController.swift index 5c542cf..35b764e 100644 --- a/Spellbook/SpellWindowController.swift +++ b/Spellbook/SpellWindowController.swift @@ -60,6 +60,9 @@ class SpellWindowController: UIViewController { @IBOutlet weak var preparedButton: ToggleButton! @IBOutlet weak var knownButton: ToggleButton! + // The cast button + @IBOutlet weak var castButton: UIButton! + @IBOutlet weak var backgroundView: UIImageView! // Spacing constraints for the spacing between the components/materials/royalties/duration labels @@ -104,6 +107,8 @@ class SpellWindowController: UIViewController { store.dispatch(TogglePropertyAction(spell: self.spell, property: .Known)) }) + castButton.addTarget(self, action: #selector(self.onCastClicked), for: UIControl.Event.touchUpInside) + // Set the content view to fill the screen contentView.frame = UIScreen.main.bounds @@ -141,6 +146,9 @@ class SpellWindowController: UIViewController { var needsLayoutUpdate = false + // Don't show the cast button for cantrips + castButton.isHidden = spell.level == 0 + // Set the text on the name label spellNameLabel.text = spell.name @@ -218,6 +226,48 @@ class SpellWindowController: UIViewController { func locationText(_ s: Spell) -> String { return s.locations.map { $0.key.code.uppercased() + " " + String($0.value) }.joined(separator: ", ") } + + @objc func onCastClicked() { + guard let profile = store.state.profile else { return } + let level = spell.level + let status = profile.spellSlotStatus + var message: String? + let storyboard = UIStoryboard(name: "Main", bundle: nil) + if level > status.maxLevelWithSlots() { + message = "\(profile.name) has no slots of level \(level) or above!" + } else if level > status.maxLevelWithAvailableSlots() { + message = "\(profile.name) has no available slots of level \(level) or above!" + } else if !spell.higherLevel.isEmpty { + let controller = storyboard.instantiateViewController(withIdentifier: "higherLevelSlotDialog") as! HigherLevelSlotController + controller.spell = spell + controller.toastController = self + let popupHeight = 0.33 * SizeUtils.screenHeight + let popupWidth = 0.75 * SizeUtils.screenWidth + let height = min(popupHeight, CGFloat(135)) + let width = min(popupWidth, CGFloat(370)) + let popupVC = PopupViewController(contentController: controller, popupWidth: width, popupHeight: height) + self.present(popupVC, animated: true, completion: nil) + } else if status.getAvailableSlots(level: level) == 0 { + let levelToUse = status.nextAvailableSlotLevel(baseLevel: level) + let controller = storyboard.instantiateViewController(withIdentifier: "confirmNextAvailableCast") as! ConfirmNextAvailableCastController + controller.spell = spell + controller.level = levelToUse + controller.toastController = self + let popupHeight = 0.33 * SizeUtils.screenHeight + let popupWidth = 0.75 * SizeUtils.screenWidth + let height = min(popupHeight, CGFloat(155)) + let width = min(popupWidth, CGFloat(370)) + let popupVC = PopupViewController(contentController: controller, popupWidth: width, popupHeight: height) + self.present(popupVC, animated: true, completion: nil) + } else { + store.dispatch(CastSpellAction(level: level)) + message = "\(spell.name) was cast" + } + + if let toast = message { + self.view.makeToast(toast, duration: Constants.toastDuration) + } + } } diff --git a/Spellbook/SpellbookActions.swift b/Spellbook/SpellbookActions.swift index 88a49c0..b083566 100644 --- a/Spellbook/SpellbookActions.swift +++ b/Spellbook/SpellbookActions.swift @@ -8,6 +8,8 @@ import ReSwift +struct GenericSpellbookAction: Action {} + struct SortFieldAction: Action { let sortField: SortField let level: Int @@ -229,3 +231,11 @@ struct RegainAllSlotsAction: Action {} // TODO: Is there a better way to do this? struct MarkAllSpellsCleanAction: Action {} + +struct CastSpellAction: Action { + let level: Int +} + +struct ToastAction: Action { + let message: String +} diff --git a/Spellbook/SpellbookMiddleware.swift b/Spellbook/SpellbookMiddleware.swift index ec70276..c7096a6 100644 --- a/Spellbook/SpellbookMiddleware.swift +++ b/Spellbook/SpellbookMiddleware.swift @@ -152,3 +152,17 @@ let deleteProfileByNameMiddleware: AppMiddleware = { } } } + +let makeToastMiddleware: AppMiddleware = { + dispatch, getState in + return { next in + return { action in + guard let toastAction = action as? ToastAction else { + next(action) + return + } + + Toast.makeToast(toastAction.message) + } + } +} diff --git a/Spellbook/SpellbookReducers.swift b/Spellbook/SpellbookReducers.swift index eb3ba6a..1d69f4a 100644 --- a/Spellbook/SpellbookReducers.swift +++ b/Spellbook/SpellbookReducers.swift @@ -351,3 +351,10 @@ func saveSettingsReducer(action: SaveSettingsAction, state: inout SpellbookAppSt SerializationUtils.saveSettings(state.settings) return state } + +func castSpellReducer(action: CastSpellAction, state: SpellbookAppState) -> SpellbookAppState { + guard let profile = state.profile else { return state } + let status = profile.spellSlotStatus + status.useSlot(level: action.level) + return state +} diff --git a/Spellbook/TextFieldChooserDelegate.swift b/Spellbook/TextFieldChooserDelegate.swift index bd7ea86..83ade28 100644 --- a/Spellbook/TextFieldChooserDelegate.swift +++ b/Spellbook/TextFieldChooserDelegate.swift @@ -10,31 +10,34 @@ import ReSwift import UIKit import CoreActionSheetPicker -class TextFieldChooserDelegate: NSObject, UITextFieldDelegate { +class TextFieldChooserDelegate: NSObject, UITextFieldDelegate { typealias ActionCreator = (T) -> A typealias ItemProvider = () -> T typealias StringGetter = (T) -> String typealias StringConstructor = (String) -> T + typealias ItemFilter = (T) -> Bool let title: String let main = Controllers.mainController let itemProvider: ItemProvider - let pickerData: [String] - let actionCreator: ActionCreator + let items: [T] + let actionCreator: ActionCreator? let nameGetter: StringGetter let textSetter: StringGetter let nameConstructor: StringConstructor + let itemFilter: ItemFilter? - init(itemProvider: @escaping ItemProvider, actionCreator: @escaping ActionCreator, nameGetter: @escaping StringGetter, textSetter: @escaping StringGetter, nameConstructor: @escaping StringConstructor, title: String) { + init(items: [T], title: String, itemProvider: @escaping ItemProvider, actionCreator: ActionCreator? = nil, nameGetter: @escaping StringGetter, textSetter: @escaping StringGetter, nameConstructor: @escaping StringConstructor, itemFilter: ItemFilter? = nil) { + self.items = items self.itemProvider = itemProvider self.actionCreator = actionCreator self.nameGetter = nameGetter self.textSetter = textSetter self.nameConstructor = nameConstructor + self.itemFilter = itemFilter self.title = title - pickerData = T.allCases.map({ nameGetter($0) }) } @@ -47,7 +50,14 @@ class TextFieldChooserDelegate: NSObject, // Get the index of the selected option let selectedItem = self.itemProvider() - let selectedIndex = T.allCases.firstIndex(of: selectedItem) as! Int + let selectedIndex: Int = items.firstIndex(of: selectedItem) ?? 0 + + var itemsToUse = self.items + if (self.itemFilter != nil) { + itemsToUse = self.items.filter(self.itemFilter!) + } + let pickerData = itemsToUse.map(self.nameGetter) + // Create the action sheet picker let actionSheetPicker = ActionSheetStringPicker(title: title, @@ -57,7 +67,9 @@ class TextFieldChooserDelegate: NSObject, picker, index, value in let valueStr = value as! String let item = self.nameConstructor(valueStr) - store.dispatch(self.actionCreator(item)) + if let creator = self.actionCreator { + store.dispatch(creator(item)) + } sender.text = self.textSetter(item) sender.endEditing(true) return @@ -70,26 +82,43 @@ class TextFieldChooserDelegate: NSObject, } +class TextFieldIterableChooserDelegate: TextFieldChooserDelegate { + + init(title: String, itemProvider: @escaping ItemProvider, actionCreator: ActionCreator? = nil, nameGetter: @escaping StringGetter, textSetter: @escaping StringGetter, nameConstructor: @escaping StringConstructor, itemFilter: ItemFilter? = nil) { + let items = T.allCases.map({ $0 }) + super.init(items: items, + title: title, + itemProvider: itemProvider, + actionCreator: actionCreator, + nameGetter: nameGetter, + textSetter: textSetter, + nameConstructor: nameConstructor, + itemFilter: itemFilter) + } + +} + // Specific cases for types that implement NameConstructible and Unit protocols // These didn't seem worth their own files, since all that needs to be overwritten is the constructor // because the name getter and constructor come directly from the protocol -class NameConstructibleChooserDelegate: TextFieldChooserDelegate { +class NameConstructibleChooserDelegate: TextFieldIterableChooserDelegate { - init(itemProvider: @escaping ItemProvider, actionCreator: @escaping ActionCreator, title: String) { - super.init(itemProvider: itemProvider, + init(itemProvider: @escaping ItemProvider, actionCreator: ActionCreator? = nil, title: String) { + super.init(title: title, + itemProvider: itemProvider, actionCreator: actionCreator, nameGetter: { $0.displayName }, textSetter: { $0.displayName }, - nameConstructor: { return N.fromName($0) }, - title: title) + nameConstructor: { return N.fromName($0) }) } } -class UnitChooserDelegate : TextFieldChooserDelegate { - init(itemProvider: @escaping ItemProvider, actionCreator: @escaping ActionCreator, title: String) { - super.init(itemProvider: itemProvider, +class UnitChooserDelegate : TextFieldIterableChooserDelegate { + init(itemProvider: @escaping ItemProvider, actionCreator: ActionCreator? = nil, title: String) { + super.init(title: title, + itemProvider: itemProvider, actionCreator: actionCreator, nameGetter: { $0.pluralName }, textSetter: SizeUtils.unitTextGetter(U.self), @@ -99,7 +128,6 @@ class UnitChooserDelegate : TextFieldChooserDelegate { } catch { return U.defaultUnit } - }, - title: title) + }) } } diff --git a/Spellbook/Toast.swift b/Spellbook/Toast.swift index 388dd12..1d436d3 100644 --- a/Spellbook/Toast.swift +++ b/Spellbook/Toast.swift @@ -11,10 +11,15 @@ import Toast class Toast { - // We make our Toast messages through the main ViewController of the app + static func makeToast(_ message: String, duration: TimeInterval = Constants.toastDuration, controller: UIViewController) { + controller.view.makeToast(message, duration: duration) + } + + // By default, we make our Toast messages through the main ViewController of the app // which is the SWRevealController // If we ever change this, we only need to change it here static func makeToast(_ message: String, duration: TimeInterval = Constants.toastDuration) { - Controllers.revealController.view.makeToast(message, duration: duration) + makeToast(message, duration: duration, controller: Controllers.revealController) } + } diff --git a/Spellbook/Util.swift b/Spellbook/Util.swift index 6a39054..88fa68e 100644 --- a/Spellbook/Util.swift +++ b/Spellbook/Util.swift @@ -1,3 +1,6 @@ +// There isn't a proper Predicate type until iOS 17 +typealias Predicate = (T) -> Bool + func yn_to_bool(yn: String) throws -> Bool { if yn == "no" { return false @@ -88,3 +91,31 @@ func complement(items: [T]) -> [T] { func arrayDifference(array arr: [T], remove: [T]) -> [T] { return arr.filter { !remove.contains($0) } } + +func ordinal(number: Int) -> String { + switch number { + case 1: + return "1st" + case 2: + return "2nd" + case 3: + return "3rd" + default: + return "\(number)th" + } +} + +func valueFrom(ordinal: String) -> Int? { + if ordinal == "1st" { + return 1 + } else if ordinal == "2nd" { + return 2 + } else if ordinal == "3rd" { + return 3 + } else if ordinal.hasSuffix("th") { + let numberString = ordinal.dropLast(2) + return Int(numberString) + } else { + return nil + } +}