diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index e507834d6..d8e768491 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -49,6 +49,8 @@ E41286F12A62E6840093D598 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E41286F02A62E6840093D598 /* KeychainSwift */; }; E41286F32A62E97B0093D598 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E41286F22A62E97B0093D598 /* KeychainSwift */; }; E41286F52A62E9840093D598 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E41286F42A62E9840093D598 /* KeychainSwift */; }; + E412DADF2B869E1E0099E483 /* BeeLemniscateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E412DADE2B869E1E0099E483 /* BeeLemniscateView.swift */; }; + E412DAE12B86A8F70099E483 /* GoalImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E412DAE02B86A8F70099E483 /* GoalImageView.swift */; }; E417572D2A6446FE0029CDDA /* CurrentUserManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E417572C2A6446FE0029CDDA /* CurrentUserManagerTests.swift */; }; E43833942AC1473E0098A38F /* InlineDatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43833932AC1473E0098A38F /* InlineDatePicker.swift */; }; E43BEA842A036A9C00FC3A38 /* LogReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43BEA832A036A9C00FC3A38 /* LogReader.swift */; }; @@ -313,6 +315,8 @@ A1F9D1E9211B9B7600E2BC93 /* EditDatapointViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditDatapointViewController.swift; sourceTree = ""; }; E4040D732A7B5F0E008E7D0E /* WorkoutMinutesHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMinutesHealthKitMetric.swift; sourceTree = ""; }; E41286ED2A62DF330093D598 /* BeeminderModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = BeeminderModel.xcdatamodel; sourceTree = ""; }; + E412DADE2B869E1E0099E483 /* BeeLemniscateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeLemniscateView.swift; sourceTree = ""; }; + E412DAE02B86A8F70099E483 /* GoalImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalImageView.swift; sourceTree = ""; }; E417572C2A6446FE0029CDDA /* CurrentUserManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUserManagerTests.swift; sourceTree = ""; }; E42CB451291727B200A35AB9 /* HealthKitError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitError.swift; sourceTree = ""; }; E43833932AC1473E0098A38F /* InlineDatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineDatePicker.swift; sourceTree = ""; }; @@ -640,6 +644,8 @@ E43D9AFA2929C37D00FC1578 /* DatapointValueAccessory.swift */, E43833932AC1473E0098A38F /* InlineDatePicker.swift */, E46DC80E2AA58DF20059FDFE /* PullToRefreshHint.swift */, + E412DADE2B869E1E0099E483 /* BeeLemniscateView.swift */, + E412DAE02B86A8F70099E483 /* GoalImageView.swift */, ); path = Components; sourceTree = ""; @@ -1147,11 +1153,13 @@ E55760F526549D310076B95A /* AddDataIntentHandler.swift in Sources */, E5C9EFE62612E02700DBBEAE /* AddDataIntents.intentdefinition in Sources */, A10DC2DF207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift in Sources */, + E412DAE12B86A8F70099E483 /* GoalImageView.swift in Sources */, A1619EA41BEECC1500E14B3A /* EditDefaultNotificationsViewController.swift in Sources */, A149B3701AEF528C00F19A09 /* SettingsViewController.swift in Sources */, E46DC80F2AA58DF20059FDFE /* PullToRefreshHint.swift in Sources */, A1E618E21E78158700D8ED93 /* HealthKitConfigViewController.swift in Sources */, E4B0833B2934620500A71564 /* DatapointTableViewController.swift in Sources */, + E412DADF2B869E1E0099E483 /* BeeLemniscateView.swift in Sources */, A11BC2D91FFAD5BC00E56064 /* TimerViewController.swift in Sources */, A1EA154D1B01E6EC0052A6E6 /* DatapointTableViewCell.swift in Sources */, A11A87C61FEBFF7200A43E47 /* ChooseGoalSortViewController.swift in Sources */, diff --git a/BeeSwift/Components/BeeLemniscateView.swift b/BeeSwift/Components/BeeLemniscateView.swift new file mode 100644 index 000000000..0ac298fcd --- /dev/null +++ b/BeeSwift/Components/BeeLemniscateView.swift @@ -0,0 +1,83 @@ +import Foundation +import SpriteKit + +/// An animated bee following a lemniscate (figure-eight) pattern. +/// Used to indicate server-side asynchronous work is taking place. +class BeeLemniscateView : UIView { + private let sceneContainer = SKView() + private let scene = SKScene() + private let beeSprite = SKSpriteNode(imageNamed: "Infinibee") + + private let SpriteRelativeSize = 0.15 + private let LemniscateAspectRatio = 1.25 + private let LemniscateMaxHeight = 0.8 + private let LemniscateMaxWidth = 0.6 + + init() { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 0)) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + private func setupView() { + self.addSubview(sceneContainer) + sceneContainer.snp.makeConstraints { (make) in + make.edges.equalToSuperview() + } + sceneContainer.allowsTransparency = true + + scene.backgroundColor = .clear + scene.addChild(beeSprite) + + sceneContainer.presentScene(scene) + } + + override func layoutSubviews() { + super.layoutSubviews() + + // Resize the scene to match the container layout + scene.size = sceneContainer.bounds.size + + // Rescale the bee sprite + let desiredSpriteWidth = scene.size.width * SpriteRelativeSize + let scaleFactor = desiredSpriteWidth / beeSprite.size.width + beeSprite.setScale(scaleFactor) + + // Calculate the bounding box for the lemniscate control points + let maximumHeightForHeight = scene.size.height * LemniscateMaxHeight + let maximumHeightForWidth = scene.size.width * LemniscateMaxWidth / LemniscateAspectRatio + + let height = min(maximumHeightForWidth, maximumHeightForHeight) + let width = height * LemniscateAspectRatio + + let lemniscateBounds = CGRect( + x: (scene.size.width - width) / 2, y: (scene.size.height - height) / 2, + width: width, height: height + ) + let centerPoint = CGPoint(x: lemniscateBounds.midX, y: lemniscateBounds.midY) + + // Create a path for the sprite to follow + let path = CGMutablePath() + path.move(to: centerPoint) + path.addCurve( + to: centerPoint, + control1: CGPoint(x: lemniscateBounds.maxX, y: lemniscateBounds.minY), + control2: CGPoint(x: lemniscateBounds.maxX, y: lemniscateBounds.maxY) + ) + path.addCurve( + to: centerPoint, + control1: CGPoint(x: lemniscateBounds.minX, y: lemniscateBounds.minY), + control2: CGPoint(x: lemniscateBounds.minX, y: lemniscateBounds.maxY) + ) + + let followPath = SKAction.follow(path, asOffset: false, orientToPath: true, duration: 2.0) + let followPathForever = SKAction.repeatForever(followPath) + + // Configure the sprite to follow the path + beeSprite.run(followPathForever) + } +} diff --git a/BeeSwift/Components/GoalImageView.swift b/BeeSwift/Components/GoalImageView.swift new file mode 100644 index 000000000..131ccdf7a --- /dev/null +++ b/BeeSwift/Components/GoalImageView.swift @@ -0,0 +1,145 @@ +import Foundation +import OSLog + +import Alamofire +import AlamofireImage + +import BeeKit + +/// Shows the current graph for a goal +/// Handles placeholders for loading and queued states, and automatically updates when the goal changes +class GoalImageView : UIView { + private static let downloader = ImageDownloader(imageCache: AutoPurgingImageCache()) + private let logger = Logger(subsystem: "com.beeminder.com", category: "GoalImageView") + + private let imageView = UIImageView() + private let beeLemniscateView = BeeLemniscateView() + + private var currentlyShowingGraph = false + private var inProgressDownload: RequestReceipt? = nil + private var currentDownloadToken: UUID? = nil + + public let isThumbnail: Bool + + public var goal: Goal? { + didSet { + // If changed to a different goal, remove any current state + if goal !== oldValue { + clearGoalGraph() + } + refresh() + } + } + + init(isThumbnail: Bool) { + self.isThumbnail = isThumbnail + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 0)) + setupView() + } + + required init?(coder: NSCoder) { + self.isThumbnail = false + super.init(coder: coder) + setupView() + } + + private func setupView() { + self.addSubview(imageView) + imageView.snp.makeConstraints { (make) in + make.edges.equalToSuperview() + } + self.imageView.image = UIImage(named: "GraphPlaceholder") + + self.addSubview(beeLemniscateView) + beeLemniscateView.snp.makeConstraints { (make) in + make.edges.equalToSuperview() + } + beeLemniscateView.isHidden = true + + NotificationCenter.default.addObserver( + forName: NSNotification.Name(rawValue: GoalManager.goalsUpdatedNotificationName), + object: nil, + queue: OperationQueue.main + ) { [weak self] _ in + self?.refresh() + } + + refresh() + } + + private func clearGoalGraph() { + imageView.image = UIImage(named: "GraphPlaceholder") + currentlyShowingGraph = false + beeLemniscateView.isHidden = true + } + + private func showGraphImage(image: UIImage) { + imageView.image = image + currentlyShowingGraph = true + beeLemniscateView.isHidden = !(goal?.queued ?? false) + } + + private func refresh() { + // Invalidate the download token, meaning that any queued download callbacks + // will no-op. This avoids race conditions with downloads finishing out of order. + let newDownloadToken = UUID() + self.currentDownloadToken = newDownloadToken + + if let downloadReceipt = inProgressDownload { + GoalImageView.downloader.cancelRequest(with: downloadReceipt) + inProgressDownload = nil + } + + // - Deadbeat: Placeholder, no animation + if ServiceLocator.currentUserManager.isDeadbeat() { + clearGoalGraph() + return + } + + // No Goal: Placeholder, no animation + guard let goal = self.goal else { + clearGoalGraph() + return + } + + // When queued, we should show a loading indicator over any existing graph, + // but not over the placeholder image. + if goal.queued ?? false { + beeLemniscateView.isHidden = !currentlyShowingGraph + } + + // Load the approppriate iamge for the goal + let urlString = if isThumbnail { + goal.cacheBustingThumbUrl + } else { + goal.cacheBustingGraphUrl + } + let request = URLRequest(url: URL(string: urlString)!) + + // Explicitly check the cache to see if the image is already present, and if so set it directly + // This avoids flicker when showing images from the cache, which will otherwise briefly show + // the placeholder while waiting for the async download callback + if let image = GoalImageView.downloader.imageCache?.image(for: request, withIdentifier: nil) { + showGraphImage(image: image) + return + } + + // Download the image and show it once downloaded + inProgressDownload = GoalImageView.downloader.download(request, completion: { response in + if newDownloadToken != self.currentDownloadToken { + // Another refresh has happend since we were enqueued. Skip performing any updates + return + } + + switch(response.result) { + case .success(let image): + // Image downloaded. Show it, and have loading indicator match queued state + self.showGraphImage(image: image) + break; + case .failure(let error): + self.logger.error("Error downloading goal graph: \(error)") + self.clearGoalGraph() + } + }) + } +} diff --git a/BeeSwift/GoalCollectionViewCell.swift b/BeeSwift/GoalCollectionViewCell.swift index c3e13b459..59eb41d21 100644 --- a/BeeSwift/GoalCollectionViewCell.swift +++ b/BeeSwift/GoalCollectionViewCell.swift @@ -14,14 +14,13 @@ class GoalCollectionViewCell: UICollectionViewCell { let slugLabel :BSLabel = BSLabel() let titleLabel :BSLabel = BSLabel() let todaytaLabel :BSLabel = BSLabel() - let thumbnailImageView :UIImageView = UIImageView() + let thumbnailImageView = GoalImageView(isThumbnail: true) let safesumLabel :BSLabel = BSLabel() let margin = 8 var goal: Goal? { didSet { - self.thumbnailImageView.image = nil - self.setThumbnailImage() + self.thumbnailImageView.goal = goal self.titleLabel.text = goal?.title self.slugLabel.text = goal?.slug self.titleLabel.isHidden = goal?.title == goal?.slug @@ -87,27 +86,4 @@ class GoalCollectionViewCell: UICollectionViewCell { required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } - - override func prepareForReuse() { - super.prepareForReuse() - - self.slugLabel.text = nil - self.titleLabel.text = nil - self.thumbnailImageView.image = UIImage(named: "ThumbnailPlaceholder") - self.safesumLabel.text = nil - self.goal = nil - } - - func deadbeatChanged() { - self.setThumbnailImage() - } - - func setThumbnailImage() { - guard let _ = self.goal else { return } - if ServiceLocator.currentUserManager.isDeadbeat() { - self.thumbnailImageView.image = UIImage(named: "ThumbnailPlaceholder") - } else { - self.thumbnailImageView.af.setImage(withURL: URL(string: self.goal!.cacheBustingThumbUrl)!, placeholderImage: UIImage(named: "ThumbnailPlaceholder"), filter: nil, progress: nil, progressQueue: DispatchQueue.global(), imageTransition: .noTransition, runImageTransitionIfCached: false, completion: nil) - } - } } diff --git a/BeeSwift/GoalViewController.swift b/BeeSwift/GoalViewController.swift index bb16f44b7..0944a00f4 100644 --- a/BeeSwift/GoalViewController.swift +++ b/BeeSwift/GoalViewController.swift @@ -24,7 +24,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl var goal : Goal! - fileprivate var goalImageView = UIImageView() + fileprivate var goalImageView = GoalImageView(isThumbnail: false) fileprivate var datapointTableController = DatapointTableViewController() fileprivate var dateTextField = UITextField() fileprivate var valueTextField = UITextField() @@ -114,8 +114,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl make.left.equalTo(self.goalImageScrollView) make.right.equalTo(self.goalImageScrollView) } - self.goalImageView.image = UIImage(named: "GraphPlaceholder") - + self.goalImageView.goal = self.goal self.scrollView.addSubview(self.deltasLabel) self.deltasLabel.snp.makeConstraints { (make) in @@ -323,9 +322,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if (!ServiceLocator.currentUserManager.signedIn()) { return } - if keyPath == "graph_url" { - self.setGraphImage() - } else if keyPath == "delta_text" || keyPath == "safebump" || keyPath == "safesum" { + if keyPath == "delta_text" || keyPath == "safebump" || keyPath == "safesum" { self.refreshCountdown() } } @@ -396,14 +393,6 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl self.countdownLabel.text = self.goal.capitalSafesum() } - func setGraphImage() { - if ServiceLocator.currentUserManager.isDeadbeat() { - self.goalImageView.image = UIImage(named: "GraphPlaceholder") - } else { - self.goalImageView.af.setImage(withURL: URL(string: self.goal.cacheBustingGraphUrl)!, placeholderImage: UIImage(named: "GraphPlaceholder"), filter: nil, progress: nil, progressQueue: DispatchQueue.global(), imageTransition: .noTransition, runImageTransitionIfCached: false, completion: nil) - } - } - @objc func goalImageTapped() { self.goalImageScrollView.setZoomScale(self.goalImageScrollView.zoomScale == 1.0 ? 2.0 : 1.0, animated: true) } @@ -545,9 +534,6 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl self.refreshCountdown() self.deltasLabel.attributedText = self.goal!.attributedDeltaText - if (!self.goal.queued!) { - self.setGraphImage() - } } func viewForZooming(in scrollView: UIScrollView) -> UIView? { diff --git a/BeeSwift/Images.xcassets/infinibee.imageset/Contents.json b/BeeSwift/Images.xcassets/infinibee.imageset/Contents.json new file mode 100644 index 000000000..4becc341d --- /dev/null +++ b/BeeSwift/Images.xcassets/infinibee.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "infinibee.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BeeSwift/Images.xcassets/infinibee.imageset/infinibee.svg b/BeeSwift/Images.xcassets/infinibee.imageset/infinibee.svg new file mode 100644 index 000000000..99c78e6c8 --- /dev/null +++ b/BeeSwift/Images.xcassets/infinibee.imageset/infinibee.svg @@ -0,0 +1,17 @@ + + \ No newline at end of file diff --git a/fastlane/README.md b/fastlane/README.md index ee5301670..f90f7b425 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -23,6 +23,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do +### ios ci + +```sh +[bundle exec] fastlane ios ci +``` + + + ### ios build ```sh