From 554418fb5f96e06ac06fb103adf96383654a1665 Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Wed, 11 May 2022 17:05:19 -0700 Subject: [PATCH 1/9] POC: Show warning banner on map when user is about to approach the threshold for maximum allowed cities to save This in accordance to #12 Also see https://iafisher.com/projects/cities/faqs --- HowManyCities.xcodeproj/project.pbxproj | 4 ++ HowManyCities/MapGuessViewController.swift | 14 ++++++ HowManyCities/Models/MapGuessViewModel.swift | 17 +++++++ HowManyCities/Views/WarningBannerView.swift | 47 ++++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 HowManyCities/Views/WarningBannerView.swift diff --git a/HowManyCities.xcodeproj/project.pbxproj b/HowManyCities.xcodeproj/project.pbxproj index fa03fdf..d1e9237 100644 --- a/HowManyCities.xcodeproj/project.pbxproj +++ b/HowManyCities.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 6C0EB7A3282C4AFB00E95F6B /* UIDismissableAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0EB7A2282C4AFB00E95F6B /* UIDismissableAlertController.swift */; }; 6C0EB7A5282C6B3B00E95F6B /* CityinfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0EB7A4282C6B3B00E95F6B /* CityinfoViewModel.swift */; }; + 6C0EB7A7282C84EB00E95F6B /* WarningBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0EB7A6282C84EB00E95F6B /* WarningBannerView.swift */; }; 6C7C8D9828161AA60091E09F /* NormalizedCountryNames.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6C7C8D9728161AA60091E09F /* NormalizedCountryNames.plist */; }; 6C7C8D9B2817BFE60091E09F /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C7C8D9A2817BFE60091E09F /* OrderedCollections */; }; 6C7C8D9E2817C1330091E09F /* MapGuessModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7C8D9D2817C1330091E09F /* MapGuessModelTests.swift */; }; @@ -115,6 +116,7 @@ 5E08EA20EB451FE21A3BAC71 /* Pods_HowManyCities.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_HowManyCities.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6C0EB7A2282C4AFB00E95F6B /* UIDismissableAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDismissableAlertController.swift; sourceTree = ""; }; 6C0EB7A4282C6B3B00E95F6B /* CityinfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CityinfoViewModel.swift; sourceTree = ""; }; + 6C0EB7A6282C84EB00E95F6B /* WarningBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarningBannerView.swift; sourceTree = ""; }; 6C7C8D9728161AA60091E09F /* NormalizedCountryNames.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NormalizedCountryNames.plist; sourceTree = ""; }; 6C7C8D9D2817C1330091E09F /* MapGuessModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapGuessModelTests.swift; sourceTree = ""; }; 6C7F4D9B2814F52000F90FDE /* State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = State.swift; sourceTree = ""; }; @@ -477,6 +479,7 @@ 6CAD802827F3C1140057A41E /* CityAnnotationView.swift */, 6CAD802E27F3D2C20057A41E /* MapGuessStatsBar.swift */, 6C9C42AB281219D0006529E9 /* MapToast.swift */, + 6C0EB7A6282C84EB00E95F6B /* WarningBannerView.swift */, ); path = Views; sourceTree = ""; @@ -805,6 +808,7 @@ 6CC3C5802804FB6C00AE5C50 /* NSLayoutConstraint+Extension.swift in Sources */, 6CAD7FE827F381DF0057A41E /* AppDelegate.swift in Sources */, 6C9F60662825AAB90035B0FA /* StateTotalPopulationRenderer.swift in Sources */, + 6C0EB7A7282C84EB00E95F6B /* WarningBannerView.swift in Sources */, 6C9C42B528122999006529E9 /* MKOverlayRenderer+Extension.swift in Sources */, 6CBBD5DA2821F7DB002D9FC2 /* CLLocation+Extension.swift in Sources */, 6C82DBE127F569F80084159F /* MKCoordinateRegion+Extension.swift in Sources */, diff --git a/HowManyCities/MapGuessViewController.swift b/HowManyCities/MapGuessViewController.swift index cb30456..15ff48f 100644 --- a/HowManyCities/MapGuessViewController.swift +++ b/HowManyCities/MapGuessViewController.swift @@ -62,6 +62,12 @@ final class MapGuessViewController: UIViewController { return button }() + private lazy var warningBanner: WarningBannerView = { + let view = WarningBannerView().autolayoutEnabled + + return view + }() + private lazy var guessStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [cityInputTextField, countryDropdownButton]).autolayoutEnabled stackView.axis = .horizontal @@ -145,6 +151,8 @@ final class MapGuessViewController: UIViewController { view.backgroundColor = .systemBackground + mapView.addSubview(warningBanner) + view.addSubview(mapView) view.addSubview(resetButton) view.addSubview(finishButton) @@ -161,6 +169,10 @@ final class MapGuessViewController: UIViewController { view.addSubview(moreStatsButton) NSLayoutConstraint.activate([ + warningBanner.topAnchor.constraint(equalTo: mapView.topAnchor), + warningBanner.leadingAnchor.constraint(equalTo: mapView.leadingAnchor), + warningBanner.trailingAnchor.constraint(equalTo: mapView.trailingAnchor), + mapView.topAnchor.constraint(equalTo: view.topAnchor), mapView.bottomAnchor.constraint(equalTo: view.centerYAnchor, constant: -64), mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -220,6 +232,8 @@ final class MapGuessViewController: UIViewController { guessStats.updateNumCitiesGuessed(viewModel.numCitiesGuessed) guessStats.updatePercentageTotalPopulation(viewModel.percentageTotalPopulationGuessed) + warningBanner.setState(viewModel.cityLimitWarning) + return annotations } diff --git a/HowManyCities/Models/MapGuessViewModel.swift b/HowManyCities/Models/MapGuessViewModel.swift index 1a007ae..0cb6460 100644 --- a/HowManyCities/Models/MapGuessViewModel.swift +++ b/HowManyCities/Models/MapGuessViewModel.swift @@ -18,6 +18,12 @@ protocol MapGuessDelegate: AnyObject { func didChangeGuessMode(_ mode: GuessMode) } +enum CityLimitWarning { + case none + case warning(_ remaining: Int) + case unableToSave(_ surplus: Int) +} + final class MapGuessViewModel: NSObject { var delegate: MapGuessDelegate? @@ -41,6 +47,17 @@ final class MapGuessViewModel: NSObject { var populationGuessed: Int { model.populationGuessed } var percentageTotalPopulationGuessed: Double { model.percentageTotalPopulationGuessed } + var cityLimitWarning: CityLimitWarning { + // TODO: Thresholds depend on game mode + if numCitiesGuessed > 7500 { + return .unableToSave(numCitiesGuessed - 7500) + } else if numCitiesGuessed >= 7000 { + return .warning(7500 - numCitiesGuessed) + } else { + return .none + } + } + init(cities: Cities? = nil) { super.init() let decoder = JSONDecoder() diff --git a/HowManyCities/Views/WarningBannerView.swift b/HowManyCities/Views/WarningBannerView.swift new file mode 100644 index 0000000..d7a92f8 --- /dev/null +++ b/HowManyCities/Views/WarningBannerView.swift @@ -0,0 +1,47 @@ +// +// WarningBannerView.swift +// HowManyCities +// +// Created by Geoffrey Liu on 5/11/22. +// + +import UIKit + +final class WarningBannerView: UIView { + private lazy var label: UILabel = { + let label = UILabel().autolayoutEnabled + label.numberOfLines = 2 + + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + isHidden = true + addSubview(label) + label.pin(to: safeAreaLayoutGuide, margins: .init(top: 0, left: 16, bottom: 8, right: 16)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setState(_ state: CityLimitWarning) { + switch state { + case .none: + isHidden = true + backgroundColor = .clear + label.text = nil + case .warning(let remaining): + isHidden = false + backgroundColor = .systemYellow + label.text = "You're approaching the limit (\(remaining) cities left)" + case .unableToSave(let surplus): + isHidden = false + backgroundColor = .systemRed + label.text = "Unable to save now, you're over the limit by \(surplus) cities" + + } + } +} From 864aac0b998c6eed14592a32b2c4a330784de176 Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Wed, 11 May 2022 17:25:45 -0700 Subject: [PATCH 2/9] An explanation --- HowManyCities.xcodeproj/project.pbxproj | 4 ++ HowManyCities/MapGuessViewController.swift | 8 +++- HowManyCities/Models/CityLimitWarning.swift | 14 ++++++ HowManyCities/Models/MapGuessViewModel.swift | 18 ++++--- HowManyCities/Views/WarningBannerView.swift | 49 ++++++++++++++------ 5 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 HowManyCities/Models/CityLimitWarning.swift diff --git a/HowManyCities.xcodeproj/project.pbxproj b/HowManyCities.xcodeproj/project.pbxproj index d1e9237..21ffdd0 100644 --- a/HowManyCities.xcodeproj/project.pbxproj +++ b/HowManyCities.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 6C0EB7A3282C4AFB00E95F6B /* UIDismissableAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0EB7A2282C4AFB00E95F6B /* UIDismissableAlertController.swift */; }; 6C0EB7A5282C6B3B00E95F6B /* CityinfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0EB7A4282C6B3B00E95F6B /* CityinfoViewModel.swift */; }; 6C0EB7A7282C84EB00E95F6B /* WarningBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0EB7A6282C84EB00E95F6B /* WarningBannerView.swift */; }; + 6C0EB7A9282C8A2000E95F6B /* CityLimitWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0EB7A8282C8A2000E95F6B /* CityLimitWarning.swift */; }; 6C7C8D9828161AA60091E09F /* NormalizedCountryNames.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6C7C8D9728161AA60091E09F /* NormalizedCountryNames.plist */; }; 6C7C8D9B2817BFE60091E09F /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C7C8D9A2817BFE60091E09F /* OrderedCollections */; }; 6C7C8D9E2817C1330091E09F /* MapGuessModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7C8D9D2817C1330091E09F /* MapGuessModelTests.swift */; }; @@ -117,6 +118,7 @@ 6C0EB7A2282C4AFB00E95F6B /* UIDismissableAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDismissableAlertController.swift; sourceTree = ""; }; 6C0EB7A4282C6B3B00E95F6B /* CityinfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CityinfoViewModel.swift; sourceTree = ""; }; 6C0EB7A6282C84EB00E95F6B /* WarningBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarningBannerView.swift; sourceTree = ""; }; + 6C0EB7A8282C8A2000E95F6B /* CityLimitWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CityLimitWarning.swift; sourceTree = ""; }; 6C7C8D9728161AA60091E09F /* NormalizedCountryNames.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NormalizedCountryNames.plist; sourceTree = ""; }; 6C7C8D9D2817C1330091E09F /* MapGuessModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapGuessModelTests.swift; sourceTree = ""; }; 6C7F4D9B2814F52000F90FDE /* State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = State.swift; sourceTree = ""; }; @@ -469,6 +471,7 @@ 6C9703D32829CAF9001F7591 /* Ratio.swift */, 6CBBD5E42823BAE7002D9FC2 /* Vibration.swift */, 6C9703D52829CB09001F7591 /* GenericError.swift */, + 6C0EB7A8282C8A2000E95F6B /* CityLimitWarning.swift */, ); path = Models; sourceTree = ""; @@ -819,6 +822,7 @@ 6C9703E0282B3870001F7591 /* CitySortMode.swift in Sources */, 6CBBD5D62821DA8F002D9FC2 /* CityInfoViewController.swift in Sources */, 6C9C42A9281219AE006529E9 /* MKZoomScale+Extension.swift in Sources */, + 6C0EB7A9282C8A2000E95F6B /* CityLimitWarning.swift in Sources */, 6CAD7FEA27F381DF0057A41E /* SceneDelegate.swift in Sources */, 6CAD802D27F3CC810057A41E /* CityAnnotation.swift in Sources */, ); diff --git a/HowManyCities/MapGuessViewController.swift b/HowManyCities/MapGuessViewController.swift index 15ff48f..c2c38ef 100644 --- a/HowManyCities/MapGuessViewController.swift +++ b/HowManyCities/MapGuessViewController.swift @@ -232,7 +232,13 @@ final class MapGuessViewController: UIViewController { guessStats.updateNumCitiesGuessed(viewModel.numCitiesGuessed) guessStats.updatePercentageTotalPopulation(viewModel.percentageTotalPopulationGuessed) - warningBanner.setState(viewModel.cityLimitWarning) + let warning = viewModel.cityLimitWarning + warningBanner.setState(warning) + if case .unableToSave(_) = warning { + finishButton.isEnabled = false + } else { + finishButton.isEnabled = true + } return annotations } diff --git a/HowManyCities/Models/CityLimitWarning.swift b/HowManyCities/Models/CityLimitWarning.swift new file mode 100644 index 0000000..6bdc655 --- /dev/null +++ b/HowManyCities/Models/CityLimitWarning.swift @@ -0,0 +1,14 @@ +// +// CityLimitWarning.swift +// HowManyCities +// +// Created by Geoffrey Liu on 5/11/22. +// + +import Foundation + +enum CityLimitWarning: Equatable { + case none + case warning(_ remaining: Int) + case unableToSave(_ surplus: Int) +} diff --git a/HowManyCities/Models/MapGuessViewModel.swift b/HowManyCities/Models/MapGuessViewModel.swift index 0cb6460..042a35b 100644 --- a/HowManyCities/Models/MapGuessViewModel.swift +++ b/HowManyCities/Models/MapGuessViewModel.swift @@ -18,12 +18,6 @@ protocol MapGuessDelegate: AnyObject { func didChangeGuessMode(_ mode: GuessMode) } -enum CityLimitWarning { - case none - case warning(_ remaining: Int) - case unableToSave(_ surplus: Int) -} - final class MapGuessViewModel: NSObject { var delegate: MapGuessDelegate? @@ -49,10 +43,14 @@ final class MapGuessViewModel: NSObject { var cityLimitWarning: CityLimitWarning { // TODO: Thresholds depend on game mode - if numCitiesGuessed > 7500 { - return .unableToSave(numCitiesGuessed - 7500) - } else if numCitiesGuessed >= 7000 { - return .warning(7500 - numCitiesGuessed) + guard let maxCities = model.gameConfiguration?.maxCities else { + return .none + } + let warningThreshold = Int(0.93*Double(maxCities)) + if numCitiesGuessed > maxCities { + return .unableToSave(numCitiesGuessed - maxCities) + } else if numCitiesGuessed >= warningThreshold { + return .warning(maxCities - numCitiesGuessed) } else { return .none } diff --git a/HowManyCities/Views/WarningBannerView.swift b/HowManyCities/Views/WarningBannerView.swift index d7a92f8..d264ede 100644 --- a/HowManyCities/Views/WarningBannerView.swift +++ b/HowManyCities/Views/WarningBannerView.swift @@ -15,12 +15,37 @@ final class WarningBannerView: UIView { return label }() + private var state: CityLimitWarning { + didSet { + guard state != oldValue else { return } + switch state { + case .none: + isHidden = true + backgroundColor = .clear + label.text = nil + case .warning(let remaining): + isHidden = false + backgroundColor = .systemYellow + // TODO: Pluralization + label.text = "Approaching save limit — \(remaining) cities left" + case .unableToSave(let surplus): + isHidden = false + backgroundColor = .systemRed + label.text = "Unable to save, exceeded limit by \(surplus) cities" + } + } + } + override init(frame: CGRect) { + state = .none super.init(frame: frame) isHidden = true addSubview(label) label.pin(to: safeAreaLayoutGuide, margins: .init(top: 0, left: 16, bottom: 8, right: 16)) + + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showExplanation))) } required init?(coder: NSCoder) { @@ -28,20 +53,14 @@ final class WarningBannerView: UIView { } func setState(_ state: CityLimitWarning) { - switch state { - case .none: - isHidden = true - backgroundColor = .clear - label.text = nil - case .warning(let remaining): - isHidden = false - backgroundColor = .systemYellow - label.text = "You're approaching the limit (\(remaining) cities left)" - case .unableToSave(let surplus): - isHidden = false - backgroundColor = .systemRed - label.text = "Unable to save now, you're over the limit by \(surplus) cities" - - } + self.state = state + } + + @objc private func showExplanation() { + let alert = UIAlertController(title: "Explanation", + message: "You cannot save a game with more than 7,500 cities. However, you can continue adding cities to your map; you just won't be able to save your results permanently and get a shareable link.", preferredStyle: .alert) + alert.addAction(.init(title: "Ok", style: .cancel)) + + parentViewController?.show(alert, sender: self) } } From 49a4716c36eb7fa92446945942327516742e9bde Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Wed, 11 May 2022 17:29:51 -0700 Subject: [PATCH 3/9] ANIMATION --- HowManyCities/Views/WarningBannerView.swift | 30 +++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/HowManyCities/Views/WarningBannerView.swift b/HowManyCities/Views/WarningBannerView.swift index d264ede..9fcee4d 100644 --- a/HowManyCities/Views/WarningBannerView.swift +++ b/HowManyCities/Views/WarningBannerView.swift @@ -18,20 +18,22 @@ final class WarningBannerView: UIView { private var state: CityLimitWarning { didSet { guard state != oldValue else { return } - switch state { - case .none: - isHidden = true - backgroundColor = .clear - label.text = nil - case .warning(let remaining): - isHidden = false - backgroundColor = .systemYellow - // TODO: Pluralization - label.text = "Approaching save limit — \(remaining) cities left" - case .unableToSave(let surplus): - isHidden = false - backgroundColor = .systemRed - label.text = "Unable to save, exceeded limit by \(surplus) cities" + UIView.animate { + switch self.state { + case .none: + self.isHidden = true + self.backgroundColor = .clear + self.label.text = nil + case .warning(let remaining): + self.isHidden = false + self.backgroundColor = .systemYellow + // TODO: Pluralization + self.label.text = "Approaching save limit — \(remaining) cities left" + case .unableToSave(let surplus): + self.isHidden = false + self.backgroundColor = .systemRed + self.label.text = "Unable to save, exceeded limit by \(surplus) cities" + } } } } From 4e097fb291181aa08cb63d209625fb87a96d1167 Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Wed, 11 May 2022 18:06:51 -0700 Subject: [PATCH 4/9] WIP: Capability to delete cities TODO: So this works, but now need to tell the map guess VC that cities have in fact been deletede --- HowManyCities/Models/MapGuessModel.swift | 8 +++++ .../Stats/GameStatsViewController.swift | 35 +++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/HowManyCities/Models/MapGuessModel.swift b/HowManyCities/Models/MapGuessModel.swift index ea0c79e..5408ddb 100644 --- a/HowManyCities/Models/MapGuessModel.swift +++ b/HowManyCities/Models/MapGuessModel.swift @@ -43,6 +43,9 @@ protocol GameStatisticsProvider: AnyObject { // key: population bracket // value: number of cities guessed vs. total cities in bracket var totalGuessedByBracket: [(Int, Ratio)] { get } + + // TODO: Damn bro this feels so illegal, maybe a different protocol? + func removeCity(_ city: City) -> City? } final class MapGuessModel: Codable { @@ -145,6 +148,11 @@ extension MapGuessModel: GameStatisticsProvider { ($1, .init(numerator: citiesExceeding(population: $1).count, denominator: gameConfig.totalCitiesByBracket[$0])) } } + + func removeCity(_ city: City) -> City? { + guessedCities.remove(city) + // TODO: Notify somehow that this city was removed???? + } } diff --git a/HowManyCities/Stats/GameStatsViewController.swift b/HowManyCities/Stats/GameStatsViewController.swift index c1815f0..052ed92 100644 --- a/HowManyCities/Stats/GameStatsViewController.swift +++ b/HowManyCities/Stats/GameStatsViewController.swift @@ -128,13 +128,44 @@ final class GameStatsViewController: UIViewController { cell.contentView.layoutMargins = .zero } - let cityCellRegistration = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in - var configuration = UIListContentConfiguration.cell() + let cityCellRegistration = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + var configuration = UIListContentConfiguration.valueCell() configuration.attributedText = self.viewModel.string(for: itemIdentifier) configuration.directionalLayoutMargins.leading = 0 configuration.directionalLayoutMargins.trailing = 0 +// configuration.image = .remove + +// let removeImage = UIImageView(image: .remove) + + let removeButton = UIButton(primaryAction: .init(title: "", image: .remove) { action in +// var snapshot = self.viewModel.dataSource.snapshot() +// if let index = snapshot.indexOfItem(.city(itemIdentifier)) { +// let ordinalItem = snapshot.indexOfItem(.ordinal(0, (index - 1) / 2, 0)) +// +// snapshot.deleteItems([.city(itemIdentifier)]) // TODO: Will have to remove the city from viewmodel too + + if let _ = self.viewModel.statsProvider?.removeCity(itemIdentifier) { + var snapshot = self.viewModel.dataSource.snapshot() + self.viewModel.refreshCityList(&snapshot) // TODO: More efficiently, couldn't we just remove the city item and its associated ordinal? + // A: I guess but then we still have to update the sections that depend on the city + // TODO: FUCK ORDINALS JUST GET RID OF THEM AND RENDER EVERYTHING IN THE SAME CVC!!!! + self.viewModel.refreshStateList(&snapshot) + self.viewModel.refreshTerritoryList(&snapshot) + self.viewModel.refreshOtherStats(&snapshot) + + self.viewModel.dataSource.apply(snapshot) + } + }) + let customAccessory = UICellAccessory.CustomViewConfiguration(customView: removeButton, + placement: .trailing(displayed: .always), + tintColor: .systemGray) cell.contentConfiguration = configuration +// cell.accessories = [.delete(displayed: .always, options: .init(isHidden: false, reservedLayoutWidth: .actual, tintColor: .systemGray, backgroundColor: nil), actionHandler: { +// // No fucking clue +// print("???") +// })] + cell.accessories = [.customView(configuration: customAccessory)] } let multiCityCellRegistration = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in From 579918230f5ef7d06c3ccd89d6ed3e18fc216870 Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Wed, 11 May 2022 18:25:00 -0700 Subject: [PATCH 5/9] Go the "long way" thru the map guess VC to remove the city. That exposes less state in the map guess model TODO: The shitty logic sure works but NOT ideal. Next would be to use annotations alone to draw our circles and stars. --- HowManyCities/MapGuessViewController.swift | 34 +++++++++++++++++-- HowManyCities/Models/MapGuessModel.swift | 12 +++---- HowManyCities/Models/MapGuessViewModel.swift | 4 +++ .../Stats/GameStatsViewController.swift | 4 ++- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/HowManyCities/MapGuessViewController.swift b/HowManyCities/MapGuessViewController.swift index c2c38ef..c11fda8 100644 --- a/HowManyCities/MapGuessViewController.swift +++ b/HowManyCities/MapGuessViewController.swift @@ -11,6 +11,10 @@ import SwifterSwift import MapCache import OrderedCollections +protocol CityEditDelegate { + func removeCity(_ city: City) -> City? +} + final class MapGuessViewController: UIViewController { private var viewModel: MapGuessViewModel @@ -228,6 +232,12 @@ final class MapGuessViewController: UIViewController { mapView.addOverlays(cities.map(by: \.asShape), level: .aboveLabels) mapView.addAnnotations(annotations) + updateGameState() + + return annotations + } + + private func updateGameState() { guessStats.updatePopulationGuessed(viewModel.populationGuessed) guessStats.updateNumCitiesGuessed(viewModel.numCitiesGuessed) guessStats.updatePercentageTotalPopulation(viewModel.percentageTotalPopulationGuessed) @@ -239,8 +249,6 @@ final class MapGuessViewController: UIViewController { } else { finishButton.isEnabled = true } - - return annotations } private func addCustomTileOverlay() { @@ -317,10 +325,32 @@ final class MapGuessViewController: UIViewController { @objc private func didTapMoreStats() { let vc = GameStatsViewController(statsProvider: viewModel.gameStatsProvider) + vc.cityEditDelegate = self present(UINavigationController(rootViewController: vc), animated: true) } } +extension MapGuessViewController: CityEditDelegate { + func removeCity(_ city: City) -> City? { + if let city = viewModel.removeCity(city) { + + if let annotation = mapView.annotations.first(where: { $0.coordinate == city.coordinates /* TODO: GOTTA BE A BETTER WAY... man */ }) { + mapView.removeAnnotation(annotation) + } + + if let overlay = mapView.overlays.first(where: { $0.coordinate == city.coordinates }) { + mapView.removeOverlay(overlay) + } + + updateGameState() + return city + } else { + // TODO: Error messaging + return nil + } + } +} + // TODO: Move this into separate file or vc??? extension MapGuessViewController { func showToast(_ message: String, toastType: ToastType) { diff --git a/HowManyCities/Models/MapGuessModel.swift b/HowManyCities/Models/MapGuessModel.swift index 5408ddb..a3df6db 100644 --- a/HowManyCities/Models/MapGuessModel.swift +++ b/HowManyCities/Models/MapGuessModel.swift @@ -43,9 +43,6 @@ protocol GameStatisticsProvider: AnyObject { // key: population bracket // value: number of cities guessed vs. total cities in bracket var totalGuessedByBracket: [(Int, Ratio)] { get } - - // TODO: Damn bro this feels so illegal, maybe a different protocol? - func removeCity(_ city: City) -> City? } final class MapGuessModel: Codable { @@ -55,6 +52,10 @@ final class MapGuessModel: Codable { var usedMultiCityInput: Bool = false var lastRegion: MKCoordinateRegion = .init(center: .zero, span: .full) + func removeCity(_ city: City) -> City? { + guessedCities.remove(city) + } + func resetState() { guessedCities = .init() } @@ -148,11 +149,6 @@ extension MapGuessModel: GameStatisticsProvider { ($1, .init(numerator: citiesExceeding(population: $1).count, denominator: gameConfig.totalCitiesByBracket[$0])) } } - - func removeCity(_ city: City) -> City? { - guessedCities.remove(city) - // TODO: Notify somehow that this city was removed???? - } } diff --git a/HowManyCities/Models/MapGuessViewModel.swift b/HowManyCities/Models/MapGuessViewModel.swift index 042a35b..615e808 100644 --- a/HowManyCities/Models/MapGuessViewModel.swift +++ b/HowManyCities/Models/MapGuessViewModel.swift @@ -125,6 +125,10 @@ final class MapGuessViewModel: NSObject { model.guessedCities } + func removeCity(_ city: City) -> City? { + model.removeCity(city) + } + func resetState() { model.resetState() } diff --git a/HowManyCities/Stats/GameStatsViewController.swift b/HowManyCities/Stats/GameStatsViewController.swift index 052ed92..3da23b3 100644 --- a/HowManyCities/Stats/GameStatsViewController.swift +++ b/HowManyCities/Stats/GameStatsViewController.swift @@ -17,6 +17,8 @@ final class GameStatsViewController: UIViewController { typealias Item = GameStatsViewModel.Item typealias ElementKind = GameStatsViewModel.ElementKind + var cityEditDelegate: CityEditDelegate? + private lazy var collectionView: UICollectionView = { let sectionProvider: UICollectionViewCompositionalLayoutSectionProvider = { (sectionIndex, environment) -> NSCollectionLayoutSection? in guard let section = Section(rawValue: sectionIndex) else { return nil } @@ -144,7 +146,7 @@ final class GameStatsViewController: UIViewController { // // snapshot.deleteItems([.city(itemIdentifier)]) // TODO: Will have to remove the city from viewmodel too - if let _ = self.viewModel.statsProvider?.removeCity(itemIdentifier) { + if let _ = self.cityEditDelegate?.removeCity(itemIdentifier) { var snapshot = self.viewModel.dataSource.snapshot() self.viewModel.refreshCityList(&snapshot) // TODO: More efficiently, couldn't we just remove the city item and its associated ordinal? // A: I guess but then we still have to update the sections that depend on the city From 7696b8bdb2fab3e216cc2b422ac2df7faa337c32 Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Sun, 22 May 2022 16:08:55 -0700 Subject: [PATCH 6/9] TEMP - remove all annotations. Will go for a dynamic approach adding annotations whenever user clicks an overlay on the map --- HowManyCities/MapGuessViewController.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/HowManyCities/MapGuessViewController.swift b/HowManyCities/MapGuessViewController.swift index c11fda8..d4d3343 100644 --- a/HowManyCities/MapGuessViewController.swift +++ b/HowManyCities/MapGuessViewController.swift @@ -227,14 +227,14 @@ final class MapGuessViewController: UIViewController { } @discardableResult - private func updateMap(_ cities: OrderedSet) -> [MKAnnotation] { - let annotations = cities.map(CityAnnotation.init) + private func updateMap(_ cities: OrderedSet) -> [CLLocationCoordinate2D] /*[MKAnnotation]*/ { +// let annotations = cities.map(CityAnnotation.init) mapView.addOverlays(cities.map(by: \.asShape), level: .aboveLabels) - mapView.addAnnotations(annotations) +// mapView.addAnnotations(annotations) updateGameState() - return annotations + return cities.map(by: \.coordinates) } private func updateGameState() { @@ -429,10 +429,11 @@ extension MapGuessViewController: MapGuessDelegate { guard let self = self else { return } self.cityInputTextField.text = "" - let annotations = self.updateMap(.init(cities)) + let coordinates = self.updateMap(.init(cities)) if cities.count > 1 { - self.mapView.showAnnotations(annotations, animated: true) +// self.mapView.showAnnotations(annotations, animated: true) + self.mapView.zoom(to: coordinates, meter: 1_000_000, edgePadding: .init(inset: 8), animated: true) // TODO: Proper pluralization self.showToast("+\(cities.count) cities, \(cities.totalPopulation.abbreviated)", toastType: .population) } else if let lastCity = cities.last { From dc30b81e456ab1fcf99e6a8329d267d6fce53b2e Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Sun, 22 May 2022 16:22:54 -0700 Subject: [PATCH 7/9] Revert "TEMP - remove all annotations. Will go for a dynamic approach adding annotations whenever user clicks an overlay on the map" I'm not convinced there's a better way, unfortunately. Now we just have to make the annotations big enough so they can be tapped. This reverts commit 7696b8bdb2fab3e216cc2b422ac2df7faa337c32. --- HowManyCities/MapGuessViewController.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/HowManyCities/MapGuessViewController.swift b/HowManyCities/MapGuessViewController.swift index d4d3343..c11fda8 100644 --- a/HowManyCities/MapGuessViewController.swift +++ b/HowManyCities/MapGuessViewController.swift @@ -227,14 +227,14 @@ final class MapGuessViewController: UIViewController { } @discardableResult - private func updateMap(_ cities: OrderedSet) -> [CLLocationCoordinate2D] /*[MKAnnotation]*/ { -// let annotations = cities.map(CityAnnotation.init) + private func updateMap(_ cities: OrderedSet) -> [MKAnnotation] { + let annotations = cities.map(CityAnnotation.init) mapView.addOverlays(cities.map(by: \.asShape), level: .aboveLabels) -// mapView.addAnnotations(annotations) + mapView.addAnnotations(annotations) updateGameState() - return cities.map(by: \.coordinates) + return annotations } private func updateGameState() { @@ -429,11 +429,10 @@ extension MapGuessViewController: MapGuessDelegate { guard let self = self else { return } self.cityInputTextField.text = "" - let coordinates = self.updateMap(.init(cities)) + let annotations = self.updateMap(.init(cities)) if cities.count > 1 { -// self.mapView.showAnnotations(annotations, animated: true) - self.mapView.zoom(to: coordinates, meter: 1_000_000, edgePadding: .init(inset: 8), animated: true) + self.mapView.showAnnotations(annotations, animated: true) // TODO: Proper pluralization self.showToast("+\(cities.count) cities, \(cities.totalPopulation.abbreviated)", toastType: .population) } else if let lastCity = cities.last { From 53e2078312335b2015021df81c879b90003d5d56 Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Sun, 22 May 2022 16:49:43 -0700 Subject: [PATCH 8/9] WIP: Resize annotation views to be closer in size to their corresponding overlays, AND to resize their frame on zoom BUG: Now the annotation views callouts are not centered so need to adjust frame once more... or the origin of that callout --- HowManyCities/MapGuessViewController.swift | 22 +++++++++++++++++--- HowManyCities/Models/CityAnnotation.swift | 4 ++++ HowManyCities/Views/CityAnnotationView.swift | 21 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/HowManyCities/MapGuessViewController.swift b/HowManyCities/MapGuessViewController.swift index c11fda8..bf277d1 100644 --- a/HowManyCities/MapGuessViewController.swift +++ b/HowManyCities/MapGuessViewController.swift @@ -31,7 +31,7 @@ final class MapGuessViewController: UIViewController { map.delegate = self - map.register(MKAnnotationView.self, forAnnotationViewWithReuseIdentifier: "MKAnnotationView") + map.register(CityAnnotationView.self, forAnnotationViewWithReuseIdentifier: "CityAnnotationView") return map }() @@ -510,9 +510,25 @@ extension MapGuessViewController: MKMapViewDelegate { return .init(overlay: overlay) } + func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) { + for annotation in mapView.annotations(in: mapView.visibleMapRect) { + if let annotation = annotation as? MKAnnotation, + let annotationView = mapView.view(for: annotation) as? CityAnnotationView { +// annotationView.transform = .init(scaleX: scaleFactor, y: scaleFactor) // WARNING!!!!! THIS WILL SCALE THE CALLOUT TOO + annotationView.setZoom(mapView.zoomLevel) + } + } + } + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { - let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "MKAnnotationView", for: annotation) - annotationView.canShowCallout = true + let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "CityAnnotationView", for: annotation) as? CityAnnotationView + annotationView?.canShowCallout = true + if let annotation = annotation as? CityAnnotation { + annotationView?.frame = .init(x: 0, y: 0, width: annotation.annotationSize, height: annotation.annotationSize) + } + +// annotationView?.backgroundColor = .black.withAlphaComponent(0.5) + return annotationView } } diff --git a/HowManyCities/Models/CityAnnotation.swift b/HowManyCities/Models/CityAnnotation.swift index 6d68ae7..215b95c 100644 --- a/HowManyCities/Models/CityAnnotation.swift +++ b/HowManyCities/Models/CityAnnotation.swift @@ -13,9 +13,13 @@ final class CityAnnotation: NSObject, MKAnnotation { var title: String? var subtitle: String? + let annotationSize: CGFloat + init(_ city: City) { self.coordinate = city.coordinates self.title = city.fullTitle self.subtitle = "pop: \(city.population.commaSeparated)" // TODO: Localize + + self.annotationSize = log10(Double(city.population)) } } diff --git a/HowManyCities/Views/CityAnnotationView.swift b/HowManyCities/Views/CityAnnotationView.swift index 5c4608f..31c9d8e 100644 --- a/HowManyCities/Views/CityAnnotationView.swift +++ b/HowManyCities/Views/CityAnnotationView.swift @@ -9,5 +9,26 @@ import Foundation import MapKit final class CityAnnotationView: MKAnnotationView { + var originalFrame: CGRect? + override func prepareForReuse() { + super.prepareForReuse() + + originalFrame = nil + } + + func setZoom(_ level: Int) { + if originalFrame == nil { + originalFrame = frame + } + + guard let originalFrame = originalFrame else { + return + } + + // TODO: FINE TUNE THIS + let scaleFactor = pow(1.5, level-3.0) + frame.size.width = originalFrame.size.width * scaleFactor + frame.size.height = originalFrame.size.height * scaleFactor + } } From 7db472e669e378f6d0ce8497895ef045d8b32f89 Mon Sep 17 00:00:00 2001 From: Geoffrey Liu Date: Sun, 22 May 2022 16:53:03 -0700 Subject: [PATCH 9/9] Don't forget to set initial zoom level --- HowManyCities/MapGuessViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/HowManyCities/MapGuessViewController.swift b/HowManyCities/MapGuessViewController.swift index bf277d1..55a59a3 100644 --- a/HowManyCities/MapGuessViewController.swift +++ b/HowManyCities/MapGuessViewController.swift @@ -526,6 +526,7 @@ extension MapGuessViewController: MKMapViewDelegate { if let annotation = annotation as? CityAnnotation { annotationView?.frame = .init(x: 0, y: 0, width: annotation.annotationSize, height: annotation.annotationSize) } + annotationView?.setZoom(mapView.zoomLevel) // annotationView?.backgroundColor = .black.withAlphaComponent(0.5)