diff --git a/HowManyCities.xcodeproj/project.pbxproj b/HowManyCities.xcodeproj/project.pbxproj index fa03fdf..21ffdd0 100644 --- a/HowManyCities.xcodeproj/project.pbxproj +++ b/HowManyCities.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* 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 */; }; + 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 */; }; @@ -115,6 +117,8 @@ 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 = ""; }; + 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 = ""; }; @@ -467,6 +471,7 @@ 6C9703D32829CAF9001F7591 /* Ratio.swift */, 6CBBD5E42823BAE7002D9FC2 /* Vibration.swift */, 6C9703D52829CB09001F7591 /* GenericError.swift */, + 6C0EB7A8282C8A2000E95F6B /* CityLimitWarning.swift */, ); path = Models; sourceTree = ""; @@ -477,6 +482,7 @@ 6CAD802827F3C1140057A41E /* CityAnnotationView.swift */, 6CAD802E27F3D2C20057A41E /* MapGuessStatsBar.swift */, 6C9C42AB281219D0006529E9 /* MapToast.swift */, + 6C0EB7A6282C84EB00E95F6B /* WarningBannerView.swift */, ); path = Views; sourceTree = ""; @@ -805,6 +811,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 */, @@ -815,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 cb30456..55a59a3 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 @@ -27,7 +31,7 @@ final class MapGuessViewController: UIViewController { map.delegate = self - map.register(MKAnnotationView.self, forAnnotationViewWithReuseIdentifier: "MKAnnotationView") + map.register(CityAnnotationView.self, forAnnotationViewWithReuseIdentifier: "CityAnnotationView") return map }() @@ -62,6 +66,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 +155,8 @@ final class MapGuessViewController: UIViewController { view.backgroundColor = .systemBackground + mapView.addSubview(warningBanner) + view.addSubview(mapView) view.addSubview(resetButton) view.addSubview(finishButton) @@ -161,6 +173,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), @@ -216,11 +232,23 @@ 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) - return annotations + let warning = viewModel.cityLimitWarning + warningBanner.setState(warning) + if case .unableToSave(_) = warning { + finishButton.isEnabled = false + } else { + finishButton.isEnabled = true + } } private func addCustomTileOverlay() { @@ -297,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) { @@ -460,9 +510,26 @@ 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?.setZoom(mapView.zoomLevel) + +// 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/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/MapGuessModel.swift b/HowManyCities/Models/MapGuessModel.swift index ea0c79e..a3df6db 100644 --- a/HowManyCities/Models/MapGuessModel.swift +++ b/HowManyCities/Models/MapGuessModel.swift @@ -52,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() } diff --git a/HowManyCities/Models/MapGuessViewModel.swift b/HowManyCities/Models/MapGuessViewModel.swift index 1a007ae..615e808 100644 --- a/HowManyCities/Models/MapGuessViewModel.swift +++ b/HowManyCities/Models/MapGuessViewModel.swift @@ -41,6 +41,21 @@ final class MapGuessViewModel: NSObject { var populationGuessed: Int { model.populationGuessed } var percentageTotalPopulationGuessed: Double { model.percentageTotalPopulationGuessed } + var cityLimitWarning: CityLimitWarning { + // TODO: Thresholds depend on game mode + 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 + } + } + init(cities: Cities? = nil) { super.init() let decoder = JSONDecoder() @@ -110,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 c1815f0..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 } @@ -128,13 +130,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.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 + // 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 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 + } } diff --git a/HowManyCities/Views/WarningBannerView.swift b/HowManyCities/Views/WarningBannerView.swift new file mode 100644 index 0000000..9fcee4d --- /dev/null +++ b/HowManyCities/Views/WarningBannerView.swift @@ -0,0 +1,68 @@ +// +// 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 + }() + + private var state: CityLimitWarning { + didSet { + guard state != oldValue else { return } + 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" + } + } + } + } + + 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) { + fatalError("init(coder:) has not been implemented") + } + + func setState(_ state: CityLimitWarning) { + 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) + } +}