Skip to content

Commit

Permalink
Add an animation when the save is complete in iOS share sheet (#74)
Browse files Browse the repository at this point in the history
* WIP

* async all the things!

* small tweaks and cleanups

* factorize some code
  • Loading branch information
casimir authored Sep 28, 2023
1 parent d360481 commit f23b4f0
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 107 deletions.
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
021BA09D2A9BFCE500557A89 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 021BA09B2A9BFCE500557A89 /* MainInterface.storyboard */; };
021BA0A12A9BFCE500557A89 /* share.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 021BA0972A9BFCE500557A89 /* share.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
02A334242AA88F5B004887D3 /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A334232AA88F5B004887D3 /* Credentials.swift */; };
02A8B8102AC1FA2E00E393D9 /* CompletionToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A8B80F2AC1FA2E00E393D9 /* CompletionToast.swift */; };
05BC92B8285B3FFB45B2EFE5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 695FF15C61FFCBBA5A997ED3 /* Pods_Runner.framework */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
Expand Down Expand Up @@ -71,6 +72,7 @@
027CBE832A9D32D8008575FC /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
02A334222AA87F35004887D3 /* share.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = share.entitlements; sourceTree = "<group>"; };
02A334232AA88F5B004887D3 /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = "<group>"; };
02A8B80F2AC1FA2E00E393D9 /* CompletionToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionToast.swift; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -131,6 +133,7 @@
021BA09B2A9BFCE500557A89 /* MainInterface.storyboard */,
021BA09E2A9BFCE500557A89 /* Info.plist */,
02A334232AA88F5B004887D3 /* Credentials.swift */,
02A8B80F2AC1FA2E00E393D9 /* CompletionToast.swift */,
);
path = share;
sourceTree = "<group>";
Expand Down Expand Up @@ -450,6 +453,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
02A8B8102AC1FA2E00E393D9 /* CompletionToast.swift in Sources */,
02A334242AA88F5B004887D3 /* Credentials.swift in Sources */,
021BA09A2A9BFCE500557A89 /* ShareViewController.swift in Sources */,
);
Expand Down
76 changes: 76 additions & 0 deletions ios/share/CompletionToast.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// CompletionToast.swift
// share
//
// Created by Martin Chaine on 25/09/2023.
//

import SwiftUI

public enum CompletionError: Error {
case description(String)
}

public enum CompletionState {
case pending, success
}

class ToastViewModel: ObservableObject {
@Published var state: CompletionState = .pending
}

public struct CompletionToast: View {
let frameSize = 100.0
let frameColor = Color.secondary.opacity(1.0)

@ObservedObject var vm: ToastViewModel
let action: () async throws -> Void
let didSucceed: () async -> Void
let onError: (String) async -> Void
let strokeColor: Color

@State private var animValue: CGFloat = .zero

public var body: some View {
switch vm.state {
case .pending:
ProgressView()
.frame(width: frameSize, height: frameSize)
.background(frameColor)
.cornerRadius(20)
.task {
do {
try await action()
vm.state = .success
} catch CompletionError.description(let message) {
await onError(message)
} catch {
await onError("error unexpected: \(error)")
}
}
case .success:
let size = frameSize / 2
Path { path in
path.move(to: CGPoint(x: size * 0.5, y: size))
path.addLine(to: CGPoint(x: size * 0.9, y: size * 1.5))
path.addLine(to: CGPoint(x: size * 1.5, y: size * 0.5))
}
.trim(from: 0, to: animValue)
.stroke(strokeColor, style: StrokeStyle(lineWidth: 8.0, lineCap: .round, lineJoin: .round))
.animation(.easeInOut, value: animValue)
.onAppear { animValue = 1.0 }
.frame(width: frameSize, height: frameSize)
.background(frameColor)
.cornerRadius(20)
.task {
do {
// leave the success feedback sink in the user mind
try await Task.sleep(nanoseconds: 400_000_000)
} catch {
devLog("task: couldn't wait a bit: \(error)")
}
await didSucceed()
}
}
}
}
196 changes: 89 additions & 107 deletions ios/share/ShareViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import UIKit
import UniformTypeIdentifiers
import Social
import SwiftUI

import OSLog
let logger = Logger()
Expand All @@ -16,6 +17,7 @@ func devLog(_ message: String) {
logger.log("[DEV] \(message, privacy: .public)")
}

let frigoligoColor = Color(UIColor(red: 0.31372550129999999, green: 0.61960786580000005, blue: 0.7607843876, alpha: 1.0))
#if DEBUG
let credentialsKey = "debug.wallabag.credentials"
#else
Expand All @@ -26,42 +28,34 @@ class ShareViewController: UIViewController {
let userDefaults = UserDefaults(suiteName: "group.net.casimir-lab.frigoligo")!
var credentials: Credentials?

let spinner = UIActivityIndicatorView()

private func loadCredentials() -> Bool {
let raw = userDefaults.string(forKey: credentialsKey)?.data(using: .utf8)
if let data = raw {
do {
credentials = try JSONDecoder().decode(Credentials.self, from: data)
return true
} catch {
// TODO it would be nice to have deeplink in there to open the log in screen directly
exitExtension(withErrorMessage: "credentials loading error: \(error)")
}
}
return false
}

override func viewDidLoad() {
super.viewDidLoad()

view.addSubview(spinner)

spinner.translatesAutoresizingMaskIntoConstraints = false
devLog("viewDidLoad()")
let toast = CompletionToast(
vm: ToastViewModel(), // FIXME init inside?
action: self.doSave,
didSucceed: { self.exitExtension() },
onError: { (errorMessage) -> Void in
self.exitExtension(withErrorMessage: errorMessage)
},
strokeColor: frigoligoColor
)
let innerController = UIHostingController(rootView: toast)
innerController.view.backgroundColor = .clear
addChild(innerController)
view.addSubview(innerController.view)
innerController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor),
innerController.view.topAnchor.constraint(equalTo: view.topAnchor),
innerController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
innerController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
innerController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
spinner.startAnimating()

doSave()
innerController.didMove(toParent: self)
}

private func exitExtension(withErrorMessage: String? = nil) {
DispatchQueue.main.async {
self.spinner.stopAnimating()
}

if (withErrorMessage != nil) {
devLog("ERROR: \(withErrorMessage!)")
let alert = UIAlertController(title: "Error", message: withErrorMessage, preferredStyle: .alert)
Expand All @@ -75,41 +69,57 @@ class ShareViewController: UIViewController {
self.present(alert, animated: true, completion: nil)
}
} else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
DispatchQueue.main.async {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}

private func doSave() {
if !loadCredentials() { return }
private func doSave() async throws {
let raw = userDefaults.string(forKey: credentialsKey)?.data(using: .utf8)
if let data = raw {
do {
credentials = try JSONDecoder().decode(Credentials.self, from: data)
} catch {
throw CompletionError.description("credentials loading error: \(error)")
}
}

guard
let items = extensionContext?.inputItems as? [NSExtensionItem],
let item = items.first,
let attachments = item.attachments,
let attachment = attachments.first
else {
exitExtension(withErrorMessage: "Could not extract attachment")
return
throw CompletionError.description("could not extract attachment")
}

if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, completionHandler: { (url, error) in
if (error != nil) {
self.exitExtension(withErrorMessage: "Could not get URL: \(error!)")
} else {
self.saveURLAndExit(url: url as! URL)
}
})
} else {
exitExtension(withErrorMessage: "Wrong attachment type for \(attachment)")
if !attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
throw CompletionError.description("wrong attachment type for \(attachment)")
}
do {
let url = try await attachment.loadItem(forTypeIdentifier: UTType.url.identifier)
try await self.sendSaveRequest(url: url as! URL)
} catch let error as CompletionError {
throw error
} catch {
throw CompletionError.description("could not get URL: \(error)")
}
}

private func getEndpoint(path: String) -> URL {
return URL(string: path, relativeTo: credentials!.server)!
private func prepareServerRequest(path: String, payload: [String: String]?) -> URLRequest {
var request = URLRequest(url: URL(string: path, relativeTo: credentials!.server)!)
request.setValue("frigoligo/ios-extension", forHTTPHeaderField: "user-agent")
if payload != nil {
request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(payload)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
}
return request
}

private func getToken() -> String? {
private func getToken() async throws -> String {
let tokenDeadline = Double(credentials!.token.expiresAt) - Date.now.timeIntervalSince1970
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
Expand All @@ -125,80 +135,52 @@ class ShareViewController: UIViewController {
payload["client_secret"] = credentials!.clientSecret
payload["grant_type"] = "refresh_token"
payload["refresh_token"] = credentials!.token.refreshToken
let request = prepareServerRequest(path: "/oauth/v2/token", payload: payload)

var request = URLRequest(url: getEndpoint(path: "/oauth/v2/token"))
request.setValue("frigoligo/ios-extension", forHTTPHeaderField:"user-agent")
request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(payload)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")

var token: String? = nil
devLog("requesting a fresh token...")
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: request) { (data, response, error) in
if (error != nil) {
self.exitExtension(withErrorMessage: error.debugDescription)
semaphore.signal()
return
}

let httpResponse = response as? HTTPURLResponse
if (httpResponse!.statusCode > 200) {
self.exitExtension(withErrorMessage: "token refresh: from server: \(String(data: data!, encoding: .utf8)!)")
} else {
do {
let payload = try JSONDecoder().decode(OAuthTokenBody.self, from: data!)
token = payload.access_token
devLog("got a new token!")

self.credentials!.token = buildTokenData(payload)
let encoder = JSONEncoder()
let rawCredentials = String(data: try encoder.encode(self.credentials), encoding: .utf8)
self.userDefaults.set(rawCredentials, forKey: credentialsKey)
devLog("updated the OAuth token")
} catch {
self.exitExtension(withErrorMessage: "credentials loading error: \(error)")
}
}
semaphore.signal()
}.resume()
semaphore.wait()
return token
let (data, response) = try await URLSession.shared.data(for: request)
let httpResponse = response as? HTTPURLResponse
if (httpResponse!.statusCode > 200) {
throw CompletionError.description("token refresh: from server: \(String(data: data, encoding: .utf8)!)")
}

do {
let payload = try JSONDecoder().decode(OAuthTokenBody.self, from: data)
let token = payload.access_token
devLog("got a new token!")

self.credentials!.token = buildTokenData(payload)
let encoder = JSONEncoder()
let rawCredentials = String(data: try encoder.encode(self.credentials), encoding: .utf8)
self.userDefaults.set(rawCredentials, forKey: credentialsKey)
devLog("updated the OAuth token")
return token
} catch let error as CompletionError {
throw error
} catch {
throw CompletionError.description("credentials updating error: \(error)")
}
}

func saveURLAndExit(url: URL) {
func sendSaveRequest(url: URL) async throws {
devLog("received an URL to save: \(url)")

var payload = [String: String]();
payload["url"] = url.description
if let tag = userDefaults.string(forKey: "settings.tagSaveLabel") {
payload["tags"] = tag
}

var request = URLRequest(url: getEndpoint(path: "/api/entries"))
request.setValue("frigoligo/ios-extension", forHTTPHeaderField:"user-agent")
request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(payload)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(getToken()!)", forHTTPHeaderField: "Authorization")
var request = prepareServerRequest(path: "/api/entries", payload: payload)
let token = try await getToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

devLog("sending save request...")
URLSession.shared.dataTask(with: request) { (data, response, error) in
if (error != nil) {
self.exitExtension(withErrorMessage: error.debugDescription)
return
}

let httpResponse = response as? HTTPURLResponse
if (httpResponse!.statusCode > 200) {
self.exitExtension(withErrorMessage: "save entry: from server: \(String(data: data!, encoding: .utf8)!)")
} else {
devLog("article saved!")
self.exitExtension()
}
}.resume()
let (data, response) = try await URLSession.shared.data(for: request)
let httpResponse = response as? HTTPURLResponse
if (httpResponse!.statusCode > 200) {
throw CompletionError.description("save entry: from server: \(String(data: data, encoding: .utf8)!)")
}
devLog("article saved!")
}
}

Expand Down

0 comments on commit f23b4f0

Please sign in to comment.