feat: add simple progress remain time estimate
This commit is contained in:
parent
fec7db2f41
commit
d6b90f40bd
|
@ -53,32 +53,58 @@ public struct AttachmentView: View {
|
||||||
if viewModel.output != nil {
|
if viewModel.output != nil {
|
||||||
VisualEffectView(effect: blurEffect)
|
VisualEffectView(effect: blurEffect)
|
||||||
VStack {
|
VStack {
|
||||||
let image: UIImage = {
|
let actionType: AttachmentView.Action = {
|
||||||
if let _ = viewModel.error {
|
if let _ = viewModel.error {
|
||||||
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate)
|
return .retry
|
||||||
} else {
|
} else {
|
||||||
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
|
return .remove
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
Image(uiImage: image)
|
Button {
|
||||||
.foregroundColor(.white)
|
action(actionType)
|
||||||
.padding()
|
} label: {
|
||||||
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
|
let image: UIImage = {
|
||||||
.clipShape(Circle())
|
switch actionType {
|
||||||
.padding()
|
case .remove:
|
||||||
|
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
|
||||||
|
case .retry:
|
||||||
|
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
Image(uiImage: image)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
|
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
|
||||||
|
.overlay(
|
||||||
|
CircleProgressView(progress: viewModel.fractionCompleted)
|
||||||
|
.animation(.default, value: viewModel.fractionCompleted)
|
||||||
|
)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
let title: String = {
|
let title: String = {
|
||||||
if let _ = viewModel.error {
|
switch actionType {
|
||||||
|
case .remove:
|
||||||
|
let totalSizeInByte = viewModel.outputSizeInByte
|
||||||
|
let uploadSizeInByte = Double(totalSizeInByte) * viewModel.progress.fractionCompleted
|
||||||
|
let total = ByteCountFormatter.string(fromByteCount: Int64(totalSizeInByte), countStyle: .memory)
|
||||||
|
let upload = ByteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte), countStyle: .memory)
|
||||||
|
return "\(upload)/\(total)"
|
||||||
|
case .retry:
|
||||||
return "Upload Failed" // TODO: i18n
|
return "Upload Failed" // TODO: i18n
|
||||||
} else {
|
|
||||||
let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.outputSizeInByte), countStyle: .memory)
|
|
||||||
return "…/\(total)"
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let subtitle: String = {
|
let subtitle: String = {
|
||||||
if let error = viewModel.error {
|
switch actionType {
|
||||||
return error.localizedDescription
|
case .remove:
|
||||||
} else {
|
if viewModel.progress.fractionCompleted < 1 {
|
||||||
return "… remaining"
|
return viewModel.remainTimeLocalizedString ?? ""
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
case .retry:
|
||||||
|
return viewModel.error?.localizedDescription ?? ""
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
Text(title)
|
Text(title)
|
||||||
|
@ -92,10 +118,6 @@ public struct AttachmentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // end ZStack
|
} // end ZStack
|
||||||
.onChange(of: viewModel.progress) { progress in
|
|
||||||
// not works…
|
|
||||||
print(progress.completedUnitCount)
|
|
||||||
}
|
|
||||||
} // end body
|
} // end body
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,12 +32,19 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
||||||
// output
|
// output
|
||||||
@Published public private(set) var output: Output?
|
@Published public private(set) var output: Output?
|
||||||
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
|
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
|
||||||
@Published public private(set) var outputSizeInByte: Int = 0
|
@Published public private(set) var outputSizeInByte: Int64 = 0
|
||||||
|
|
||||||
@Published public var uploadResult: UploadResult?
|
@Published public var uploadResult: UploadResult?
|
||||||
@Published var error: Error?
|
@Published var error: Error?
|
||||||
|
|
||||||
let progress = Progress() // upload progress
|
let progress = Progress() // upload progress
|
||||||
|
@Published var fractionCompleted: Double = 0
|
||||||
|
|
||||||
|
var displayLink: CADisplayLink!
|
||||||
|
private var lastTimestamp: TimeInterval?
|
||||||
|
private var lastUploadSizeInByte: Int64 = 0
|
||||||
|
private var averageUploadSpeedInByte: Int64 = 0
|
||||||
|
@Published var remainTimeLocalizedString: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
api: APIService,
|
api: APIService,
|
||||||
|
@ -49,26 +56,34 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
||||||
self.input = input
|
self.input = input
|
||||||
super.init()
|
super.init()
|
||||||
// end init
|
// end init
|
||||||
|
|
||||||
|
self.displayLink = CADisplayLink(
|
||||||
|
target: self,
|
||||||
|
selector: #selector(AttachmentViewModel.step(displayLink:))
|
||||||
|
)
|
||||||
|
displayLink.add(to: .current, forMode: .common)
|
||||||
|
|
||||||
progress
|
progress
|
||||||
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
|
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
|
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
|
||||||
|
self.fractionCompleted = progress.fractionCompleted
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &observations)
|
.store(in: &observations)
|
||||||
|
|
||||||
progress
|
// Note: this observation is redundant if .fractionCompleted listener always emit event when reach 1.0 progress
|
||||||
.observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
|
// progress
|
||||||
guard let self = self else { return }
|
// .observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
|
||||||
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
|
// guard let self = self else { return }
|
||||||
DispatchQueue.main.async {
|
// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
|
||||||
self.objectWillChange.send()
|
// DispatchQueue.main.async {
|
||||||
}
|
// self.objectWillChange.send()
|
||||||
}
|
// }
|
||||||
.store(in: &observations)
|
// }
|
||||||
|
// .store(in: &observations)
|
||||||
|
|
||||||
$output
|
$output
|
||||||
.map { output -> UIImage? in
|
.map { output -> UIImage? in
|
||||||
|
@ -89,7 +104,7 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
||||||
do {
|
do {
|
||||||
let output = try await load(input: input)
|
let output = try await load(input: input)
|
||||||
self.output = output
|
self.output = output
|
||||||
self.outputSizeInByte = output.asAttachment.sizeInByte ?? 0
|
self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0
|
||||||
let uploadResult = try await self.upload(context: .init(
|
let uploadResult = try await self.upload(context: .init(
|
||||||
apiService: self.api,
|
apiService: self.api,
|
||||||
authContext: self.authContext
|
authContext: self.authContext
|
||||||
|
@ -103,6 +118,9 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
displayLink.invalidate()
|
||||||
|
displayLink.remove(from: .current, forMode: .common)
|
||||||
|
|
||||||
switch output {
|
switch output {
|
||||||
case .image:
|
case .image:
|
||||||
// FIXME:
|
// FIXME:
|
||||||
|
@ -115,6 +133,58 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calculate the upload speed
|
||||||
|
// ref: https://stackoverflow.com/a/3841706/3797903
|
||||||
|
extension AttachmentViewModel {
|
||||||
|
|
||||||
|
static var SpeedSmoothingFactor = 0.4
|
||||||
|
static let remainsTimeFormatter: RelativeDateTimeFormatter = {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
@objc private func step(displayLink: CADisplayLink) {
|
||||||
|
guard let lastTimestamp = self.lastTimestamp else {
|
||||||
|
self.lastTimestamp = displayLink.timestamp
|
||||||
|
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * progress.fractionCompleted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration = displayLink.timestamp - lastTimestamp
|
||||||
|
guard duration >= 1.0 else { return } // update every 1 sec
|
||||||
|
|
||||||
|
let old = self.lastUploadSizeInByte
|
||||||
|
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * progress.fractionCompleted)
|
||||||
|
|
||||||
|
let newSpeed = self.lastUploadSizeInByte - old
|
||||||
|
let lastAverageSpeed = self.averageUploadSpeedInByte
|
||||||
|
let newAverageSpeed = Int64(AttachmentViewModel.SpeedSmoothingFactor * Double(newSpeed) + (1 - AttachmentViewModel.SpeedSmoothingFactor) * Double(lastAverageSpeed))
|
||||||
|
|
||||||
|
let remainSizeInByte = Double(outputSizeInByte) * (1 - progress.fractionCompleted)
|
||||||
|
|
||||||
|
let speed = Double(newAverageSpeed)
|
||||||
|
if speed != .zero {
|
||||||
|
// estimate by speed
|
||||||
|
let uploadRemainTimeInSecond = remainSizeInByte / speed
|
||||||
|
// estimate by progress 1s for 10%
|
||||||
|
let remainPercentage = 1 - progress.fractionCompleted
|
||||||
|
let estimateRemainTimeByProgress = remainPercentage / 0.1
|
||||||
|
// max estimate
|
||||||
|
let remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond)
|
||||||
|
|
||||||
|
let string = AttachmentViewModel.remainsTimeFormatter.localizedString(fromTimeInterval: remainTimeInSecond)
|
||||||
|
remainTimeLocalizedString = string
|
||||||
|
// print("remains: \(remainSizeInByte), speed: \(newAverageSpeed), \(string)")
|
||||||
|
} else {
|
||||||
|
remainTimeLocalizedString = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lastTimestamp = displayLink.timestamp
|
||||||
|
self.averageUploadSpeedInByte = newAverageSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension AttachmentViewModel {
|
extension AttachmentViewModel {
|
||||||
public enum Input: Hashable {
|
public enum Input: Hashable {
|
||||||
case image(UIImage)
|
case image(UIImage)
|
||||||
|
|
|
@ -278,6 +278,11 @@ extension ComposeContentViewModel {
|
||||||
// MARK: - UITextViewDelegate
|
// MARK: - UITextViewDelegate
|
||||||
extension ComposeContentViewModel: UITextViewDelegate {
|
extension ComposeContentViewModel: UITextViewDelegate {
|
||||||
public func textViewDidBeginEditing(_ textView: UITextView) {
|
public func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
// Note:
|
||||||
|
// Xcode warning:
|
||||||
|
// Publishing changes from within view updates is not allowed, this will cause undefined behavior.
|
||||||
|
//
|
||||||
|
// Just ignore the warning and see what will happen…
|
||||||
switch textView {
|
switch textView {
|
||||||
case contentMetaText?.textView:
|
case contentMetaText?.textView:
|
||||||
isContentEditing = true
|
isContentEditing = true
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// CircleProgressView.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by MainasuK on 2022/11/10.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// https://stackoverflow.com/a/71467536/3797903
|
||||||
|
struct CircleProgressView: View {
|
||||||
|
|
||||||
|
let progress: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let lineWidth: CGFloat = 4
|
||||||
|
let tintColor = Color.white
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0.0, to: CGFloat(progress))
|
||||||
|
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt, lineJoin: .bevel))
|
||||||
|
.foregroundColor(tintColor)
|
||||||
|
.rotationEffect(Angle(degrees: 270.0))
|
||||||
|
}
|
||||||
|
.padding(ceil(lineWidth / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue