Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show lemniscate for queued goals #444

Merged
merged 1 commit into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions BeeSwift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -313,6 +315,8 @@
A1F9D1E9211B9B7600E2BC93 /* EditDatapointViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditDatapointViewController.swift; sourceTree = "<group>"; };
E4040D732A7B5F0E008E7D0E /* WorkoutMinutesHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMinutesHealthKitMetric.swift; sourceTree = "<group>"; };
E41286ED2A62DF330093D598 /* BeeminderModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = BeeminderModel.xcdatamodel; sourceTree = "<group>"; };
E412DADE2B869E1E0099E483 /* BeeLemniscateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeLemniscateView.swift; sourceTree = "<group>"; };
E412DAE02B86A8F70099E483 /* GoalImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalImageView.swift; sourceTree = "<group>"; };
E417572C2A6446FE0029CDDA /* CurrentUserManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUserManagerTests.swift; sourceTree = "<group>"; };
E42CB451291727B200A35AB9 /* HealthKitError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitError.swift; sourceTree = "<group>"; };
E43833932AC1473E0098A38F /* InlineDatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineDatePicker.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -640,6 +644,8 @@
E43D9AFA2929C37D00FC1578 /* DatapointValueAccessory.swift */,
E43833932AC1473E0098A38F /* InlineDatePicker.swift */,
E46DC80E2AA58DF20059FDFE /* PullToRefreshHint.swift */,
E412DADE2B869E1E0099E483 /* BeeLemniscateView.swift */,
E412DAE02B86A8F70099E483 /* GoalImageView.swift */,
);
path = Components;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
83 changes: 83 additions & 0 deletions BeeSwift/Components/BeeLemniscateView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
145 changes: 145 additions & 0 deletions BeeSwift/Components/GoalImageView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
})
}
}
28 changes: 2 additions & 26 deletions BeeSwift/GoalCollectionViewCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
20 changes: 3 additions & 17 deletions BeeSwift/GoalViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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? {
Expand Down
21 changes: 21 additions & 0 deletions BeeSwift/Images.xcassets/infinibee.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading