368 lines
13 KiB
Swift
368 lines
13 KiB
Swift
//
|
|
// ScanView.swift
|
|
// Zashi
|
|
//
|
|
// Created by Lukáš Korba on 16.05.2022.
|
|
//
|
|
|
|
import SwiftUI
|
|
import ComposableArchitecture
|
|
|
|
import Generated
|
|
import UIComponents
|
|
|
|
public struct ScanView: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@Environment(\.openURL) var openURL
|
|
|
|
@State private var image: UIImage?
|
|
@State private var showSheet = false
|
|
|
|
let store: StoreOf<Scan>
|
|
let popoverRatio: CGFloat
|
|
|
|
public init(store: StoreOf<Scan>, popoverRatio: CGFloat = 1.0) {
|
|
self.store = store
|
|
self.popoverRatio = popoverRatio
|
|
}
|
|
|
|
public var body: some View {
|
|
WithPerceptionTracking {
|
|
ZStack {
|
|
if showSheet {
|
|
ZashiImagePicker(selectedImage: $image, showSheet: $showSheet)
|
|
} else {
|
|
GeometryReader { proxy in
|
|
QRCodeScanView(
|
|
rectOfInterest: ScanView.normalizedRectsOfInterest(popoverRatio).real,
|
|
onQRScanningDidFail: { store.send(.scanFailed(.invalidQRCode)) },
|
|
onQRScanningSucceededWithCode: { store.send(.scan($0.redacted)) }
|
|
)
|
|
|
|
frameOfInterest(proxy.size)
|
|
|
|
WithPerceptionTracking {
|
|
if store.isTorchAvailable {
|
|
torchButton(size: proxy.size)
|
|
}
|
|
|
|
if !store.forceLibraryToHide {
|
|
libraryButton(size: proxy.size)
|
|
}
|
|
}
|
|
|
|
WithPerceptionTracking {
|
|
if store.progress != nil {
|
|
WithPerceptionTracking {
|
|
progress(size: proxy.size, progress: store.countedProgress)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
VStack {
|
|
WithPerceptionTracking {
|
|
if let instructions = store.instructions {
|
|
Text(instructions)
|
|
.font(.custom(FontFamily.Inter.semiBold.name, size: 20))
|
|
.foregroundColor(Asset.Colors.ZDesign.shark200.color)
|
|
.padding(.top, 64)
|
|
.lineLimit(nil)
|
|
.multilineTextAlignment(.center)
|
|
.lineSpacing(3)
|
|
.screenHorizontalPadding()
|
|
}
|
|
|
|
Spacer()
|
|
|
|
HStack(alignment: .top, spacing: 0) {
|
|
if !store.info.isEmpty {
|
|
Asset.Assets.infoOutline.image
|
|
.zImage(size: 20, color: Asset.Colors.ZDesign.shark200.color)
|
|
.padding(.trailing, 12)
|
|
|
|
Text(store.info)
|
|
.font(.custom(FontFamily.Inter.medium.name, size: 12))
|
|
.foregroundColor(Asset.Colors.ZDesign.shark200.color)
|
|
.padding(.top, 2)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.padding(.bottom, 15)
|
|
|
|
if !store.isCameraEnabled {
|
|
primaryButton(L10n.Scan.openSettings) {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
openURL(url)
|
|
}
|
|
}
|
|
} else {
|
|
primaryButton(L10n.General.cancel) {
|
|
store.send(.cancelTapped)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.screenHorizontalPadding()
|
|
}
|
|
}
|
|
.edgesIgnoringSafeArea(.all)
|
|
.ignoresSafeArea()
|
|
.applyScreenBackground()
|
|
.onAppear { store.send(.onAppear) }
|
|
.onDisappear { store.send(.onDisappear) }
|
|
.zashiBackV2(hidden: store.isCameraEnabled, invertedColors: colorScheme == .light) {
|
|
store.send(.cancelTapped)
|
|
}
|
|
.onChange(of: image) { img in
|
|
if let img {
|
|
store.send(.libraryImage(img))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func primaryButton(_ text: String, action: @escaping () -> Void) -> some View {
|
|
Button {
|
|
action()
|
|
} label: {
|
|
Text(text)
|
|
.font(.custom(FontFamily.Inter.semiBold.name, size: 16))
|
|
.foregroundColor(Asset.Colors.ZDesign.Base.obsidian.color)
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: Design.Radius._xl)
|
|
.fill(Asset.Colors.ZDesign.Base.bone.color)
|
|
}
|
|
}
|
|
.padding(.bottom, 40)
|
|
}
|
|
|
|
private func torchButton(size: CGSize) -> some View {
|
|
let topLeft = ScanView.rectOfInterest(size, popoverRatio).origin
|
|
let frameSize = ScanView.frameSize(size, popoverRatio)
|
|
|
|
return WithPerceptionTracking {
|
|
Button {
|
|
store.send(.torchTapped)
|
|
} label: {
|
|
if store.isTorchOn {
|
|
Asset.Assets.Icons.flashOff.image
|
|
.zImage(size: 24, color: Asset.Colors.ZDesign.shark50.color)
|
|
.padding(12)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: Design.Radius._xl)
|
|
.fill(Asset.Colors.ZDesign.shark900.color)
|
|
}
|
|
} else {
|
|
Asset.Assets.Icons.flashOn.image
|
|
.zImage(size: 24, color: Asset.Colors.ZDesign.shark50.color)
|
|
.padding(12)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: Design.Radius._xl)
|
|
.fill(Asset.Colors.ZDesign.shark900.color)
|
|
}
|
|
}
|
|
}
|
|
.position(
|
|
x: topLeft.x + frameSize.width * 0.5 + (store.forceLibraryToHide ? 0 : 35),
|
|
y: topLeft.y + frameSize.height + 45
|
|
)
|
|
}
|
|
}
|
|
|
|
private func libraryButton(size: CGSize) -> some View {
|
|
let topLeft = ScanView.rectOfInterest(size, popoverRatio).origin
|
|
let frameSize = ScanView.frameSize(size, popoverRatio)
|
|
|
|
return WithPerceptionTracking {
|
|
Button {
|
|
showSheet = true
|
|
} label: {
|
|
Asset.Assets.Icons.imageLibrary.image
|
|
.zImage(size: 24, color: Asset.Colors.ZDesign.shark50.color)
|
|
.padding(12)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: Design.Radius._xl)
|
|
.fill(Asset.Colors.ZDesign.shark900.color)
|
|
}
|
|
}
|
|
.position(
|
|
x: topLeft.x + frameSize.width * 0.5 - (store.isTorchAvailable ? 35 : 0),
|
|
y: topLeft.y + frameSize.height + 45
|
|
)
|
|
}
|
|
}
|
|
|
|
private func progress(size: CGSize, progress: Int) -> some View {
|
|
let topLeft = ScanView.rectOfInterest(size, popoverRatio).origin
|
|
let frameSize = ScanView.frameSize(size, popoverRatio)
|
|
|
|
return VStack {
|
|
Text(String(format: "%d%%", progress))
|
|
.font(.custom(FontFamily.Inter.semiBold.name, size: 16))
|
|
.foregroundColor(Asset.Colors.ZDesign.shark50.color)
|
|
.padding(.bottom, 4)
|
|
ProgressView(value: Float(progress), total: Float(100))
|
|
}
|
|
.frame(width: frameSize.width * 0.8)
|
|
.tint(Asset.Colors.ZDesign.Base.brand.color)
|
|
.position(
|
|
x: topLeft.x + frameSize.width * 0.5,
|
|
y: topLeft.y - 56
|
|
)
|
|
}
|
|
}
|
|
|
|
extension ScanView {
|
|
func frameOfInterest(_ size: CGSize) -> some View {
|
|
let topLeft = ScanView.rectOfInterest(size, popoverRatio).origin
|
|
let frameSize = ScanView.frameSize(size, popoverRatio)
|
|
let sizeOfTheMark = 40.0
|
|
let markShiftSize = 18.0
|
|
|
|
return ZStack {
|
|
Color.black
|
|
.opacity(0.65)
|
|
.edgesIgnoringSafeArea(.all)
|
|
.ignoresSafeArea()
|
|
.reverseMask(alignment: .topLeading) {
|
|
RoundedRectangle(cornerRadius: 28)
|
|
.frame(
|
|
width: frameSize.width,
|
|
height: frameSize.height,
|
|
alignment: .topLeading
|
|
)
|
|
.offset(
|
|
x: topLeft.x,
|
|
y: topLeft.y
|
|
)
|
|
}
|
|
|
|
// top right
|
|
Asset.Assets.scanMark.image
|
|
.resizable()
|
|
.frame(width: sizeOfTheMark, height: sizeOfTheMark)
|
|
.position(
|
|
x: topLeft.x + frameSize.width - markShiftSize,
|
|
y: topLeft.y + markShiftSize
|
|
)
|
|
|
|
// top left
|
|
Asset.Assets.scanMark.image
|
|
.resizable()
|
|
.frame(width: sizeOfTheMark, height: sizeOfTheMark)
|
|
.rotationEffect(Angle(degrees: 270))
|
|
.position(
|
|
x: topLeft.x + markShiftSize,
|
|
y: topLeft.y + markShiftSize
|
|
)
|
|
|
|
// bottom left
|
|
Asset.Assets.scanMark.image
|
|
.resizable()
|
|
.frame(width: sizeOfTheMark, height: sizeOfTheMark)
|
|
.rotationEffect(Angle(degrees: 180))
|
|
.position(
|
|
x: topLeft.x + markShiftSize,
|
|
y: topLeft.y + frameSize.height - markShiftSize
|
|
)
|
|
|
|
// bottom right
|
|
Asset.Assets.scanMark.image
|
|
.resizable()
|
|
.frame(width: sizeOfTheMark, height: sizeOfTheMark)
|
|
.rotationEffect(Angle(degrees: 90))
|
|
.position(
|
|
x: topLeft.x + frameSize.width - markShiftSize,
|
|
y: topLeft.y + frameSize.height - markShiftSize
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
@inlinable
|
|
public func reverseMask<Mask: View>(
|
|
alignment: Alignment = .center,
|
|
@ViewBuilder _ mask: () -> Mask
|
|
) -> some View {
|
|
self.mask {
|
|
Rectangle()
|
|
.overlay(alignment: alignment) {
|
|
mask()
|
|
.blendMode(.destinationOut)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ScanView {
|
|
static func frameSize(_ size: CGSize, _ popoverRatio: CGFloat) -> CGSize {
|
|
let rect = normalizedRectsOfInterest(popoverRatio).renderOnly
|
|
|
|
return CGSize(width: rect.width * size.width, height: rect.height * size.height)
|
|
}
|
|
|
|
static func rectOfInterest(_ size: CGSize, _ popoverRatio: CGFloat) -> CGRect {
|
|
let rect = normalizedRectsOfInterest(popoverRatio).renderOnly
|
|
|
|
return CGRect(
|
|
x: size.width * rect.origin.x,
|
|
y: size.height * rect.origin.y,
|
|
width: frameSize(size, popoverRatio).width,
|
|
height: frameSize(size, popoverRatio).height
|
|
)
|
|
}
|
|
|
|
static func normalizedRectsOfInterest(_ popoverRatio: CGFloat) -> (renderOnly: CGRect, real: CGRect) {
|
|
let rect = UIScreen.main.bounds
|
|
|
|
let readRectSize = 0.6
|
|
|
|
let topLeftX = (1.0 - readRectSize) * 0.5
|
|
let ratio = rect.width / rect.height
|
|
let rectHeight = ratio * readRectSize * popoverRatio
|
|
let topLeftY = (1.0 - rectHeight) * 0.5
|
|
|
|
return (
|
|
renderOnly: CGRect(
|
|
x: topLeftX,
|
|
y: topLeftY,
|
|
width: readRectSize,
|
|
height: rectHeight
|
|
), real: CGRect(
|
|
x: topLeftX,
|
|
y: topLeftX,
|
|
width: readRectSize,
|
|
height: readRectSize
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct ScanView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
ScanView(store: Scan.placeholder)
|
|
}
|
|
}
|
|
|
|
// MARK: Placeholders
|
|
|
|
extension Scan.State {
|
|
public static var initial = Scan.State()
|
|
}
|
|
|
|
extension Scan {
|
|
public static let placeholder = StoreOf<Scan>(
|
|
initialState: .initial
|
|
) {
|
|
Scan()
|
|
}
|
|
}
|