Skip to content

Commit

Permalink
Add QR code scanner (#2576)
Browse files Browse the repository at this point in the history
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
Add QR code scanner that can be triggered from HA and receive result
response
## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->
  


https://github.com/home-assistant/iOS/assets/5808343/0e4d4f18-7507-44f2-8844-e701448943dd


## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
  • Loading branch information
bgoncal committed Feb 19, 2024
1 parent ae4a3e8 commit 02a8457
Show file tree
Hide file tree
Showing 12 changed files with 693 additions and 15 deletions.
36 changes: 36 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,11 @@
420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420FE84A2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift */; };
420FE84E2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420FE84D2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift */; };
420FE8502B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420FE84F2B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift */; };
42266B112B740E4C00E94A71 /* BarcodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */; };
42266B1D2B741FB400E94A71 /* BarcodeScannerCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B172B741FB300E94A71 /* BarcodeScannerCameraView.swift */; };
42266B1F2B741FB400E94A71 /* BarcodeScannerDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B192B741FB300E94A71 /* BarcodeScannerDataModel.swift */; };
42266B202B741FB400E94A71 /* BarcodeScannerCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B1A2B741FB400E94A71 /* BarcodeScannerCamera.swift */; };
42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */; };
424A7F462B188946008C8DF3 /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F452B188946008C8DF3 /* WidgetBackground.swift */; };
424A7F482B188BF3008C8DF3 /* WidgetContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */; };
424DD05A2B3509170057E456 /* CarPlayActionsTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424DD0592B3509170057E456 /* CarPlayActionsTemplate.swift */; };
Expand Down Expand Up @@ -1569,6 +1574,11 @@
420FE84A2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarPlayActionsTemplate+Build.swift"; sourceTree = "<group>"; };
420FE84D2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayEntitiesListViewModel.swift; sourceTree = "<group>"; };
420FE84F2B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarPlayEntitiesListTemplate+Build.swift"; sourceTree = "<group>"; };
42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerView.swift; sourceTree = "<group>"; };
42266B172B741FB300E94A71 /* BarcodeScannerCameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BarcodeScannerCameraView.swift; sourceTree = "<group>"; };
42266B192B741FB300E94A71 /* BarcodeScannerDataModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BarcodeScannerDataModel.swift; sourceTree = "<group>"; };
42266B1A2B741FB400E94A71 /* BarcodeScannerCamera.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BarcodeScannerCamera.swift; sourceTree = "<group>"; };
42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerViewModel.swift; sourceTree = "<group>"; };
4242A2B12B2B5C8000E9F001 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
4242A2B22B2B5C8100E9F001 /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "ca-ES"; path = "ca-ES.lproj/AppIntentVocabulary.plist"; sourceTree = "<group>"; };
4242A2B32B2B5C8100E9F001 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "zh-Hans"; path = "zh-Hans.lproj/AppIntentVocabulary.plist"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3052,6 +3062,26 @@
path = Entities;
sourceTree = "<group>";
};
42266B0F2B740E3600E94A71 /* QRCodeScanner */ = {
isa = PBXGroup;
children = (
42266B152B741F9F00E94A71 /* Camera */,
42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */,
42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */,
);
path = QRCodeScanner;
sourceTree = "<group>";
};
42266B152B741F9F00E94A71 /* Camera */ = {
isa = PBXGroup;
children = (
42266B1A2B741FB400E94A71 /* BarcodeScannerCamera.swift */,
42266B172B741FB300E94A71 /* BarcodeScannerCameraView.swift */,
42266B192B741FB300E94A71 /* BarcodeScannerDataModel.swift */,
);
path = Camera;
sourceTree = "<group>";
};
425573C52B55729E00145217 /* Servers */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3493,6 +3523,7 @@
B657A8E81CA646EB00121384 /* App */ = {
isa = PBXGroup;
children = (
42266B0F2B740E3600E94A71 /* QRCodeScanner */,
B657A8E91CA646EB00121384 /* AppDelegate.swift */,
D03D893720E0AF1B00D4F28D /* ClientEvents */,
11A183B22511BCF300CA326A /* LifecycleManager.swift */,
Expand Down Expand Up @@ -5610,6 +5641,7 @@
425573CC2B5574AD00145217 /* CarPlayAreasZonesTemplate+Build.swift in Sources */,
B626AAF11D8F972800A0D225 /* SettingsDetailViewController.swift in Sources */,
1127381C2622B6F300F5E312 /* DebugSettingsViewController.swift in Sources */,
42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */,
11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */,
11DA6B4F2713912F008ADFAF /* OnboardingPermissionViewController.swift in Sources */,
42CA28BB2B1028330093B31A /* SimulatorThreadClientService.swift in Sources */,
Expand All @@ -5633,6 +5665,7 @@
B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */,
B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */,
119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */,
42266B202B741FB400E94A71 /* BarcodeScannerCamera.swift in Sources */,
11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */,
FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */,
11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */,
Expand Down Expand Up @@ -5672,6 +5705,7 @@
42F1DA612B4D4F31002729BC /* CarPlayNoServerAlert.swift in Sources */,
11C590ED24A832CA0066085D /* YamlSection.swift in Sources */,
42F1DA5B2B4BF7DF002729BC /* WindowSizeObserver.swift in Sources */,
42266B1D2B741FB400E94A71 /* BarcodeScannerCameraView.swift in Sources */,
11EFCDD624F5FA8D00314D85 /* WebViewSceneDelegate.swift in Sources */,
1185DF94271FBA6100ED7D9A /* OnboardingAuthDetails.swift in Sources */,
420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */,
Expand Down Expand Up @@ -5712,6 +5746,7 @@
1185DFB3271FF53800ED7D9A /* OnboardingAuthStepSensors.swift in Sources */,
11108D632634C8FE009DAB0F /* LearnMoreButtonRow.swift in Sources */,
425573D12B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift in Sources */,
42266B112B740E4C00E94A71 /* BarcodeScannerView.swift in Sources */,
11B62DBE24F2EDD800E5CB55 /* EurekaCondition+Additions.swift in Sources */,
B661FB74226C110A00E541DD /* OnboardingWelcomeViewController.swift in Sources */,
1164DA2125FBEE8600515E8A /* TemplateEditViewController.swift in Sources */,
Expand All @@ -5735,6 +5770,7 @@
42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */,
11EFCDD324F5F39100314D85 /* WebViewWindowController.swift in Sources */,
11EFCDE024F60E5900314D85 /* BasicSceneDelegate.swift in Sources */,
42266B1F2B741FB400E94A71 /* BarcodeScannerDataModel.swift in Sources */,
425573C92B5572DB00145217 /* CarPlayServerListViewModel.swift in Sources */,
11A71C6F24A4644A00D9565F /* ZoneManagerIgnoreReason.swift in Sources */,
1101568324D770B2009424C9 /* iOSTagManager.swift in Sources */,
Expand Down
136 changes: 136 additions & 0 deletions Sources/App/QRCodeScanner/BarcodeScannerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Shared
import SwiftUI

struct BarcodeScannerView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: BarcodeScannerViewModel
// Use single data model so both camera previews use same camera stream
@State private var cameraDataModel = BarcodeScannerDataModel()
private let cameraSquareSize: CGFloat = 320
private let flashlightIcon = MaterialDesignIcons.flashlightIcon.image(
ofSize: .init(width: 24, height: 24),
color: .white
)

private let title: String
private let description: String
private let alternativeOptionLabel: String?

init(
title: String,
description: String,
alternativeOptionLabel: String? = nil,
incomingMessageId: Int
) {
self.title = title
self.description = description
self.alternativeOptionLabel = alternativeOptionLabel
self._viewModel = .init(wrappedValue: .init(incomingMessageId: incomingMessageId))
}

var body: some View {
ZStack(alignment: .top) {
ZStack {
cameraBackground
cameraSquare
}
.ignoresSafeArea()
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity)

topInformation
}
.onAppear {
cameraDataModel.camera.qrFound = { code, format in
viewModel.scannedCode(code, format: format)
}
}
}

private var topInformation: some View {
VStack(spacing: 8) {
Button(action: {
viewModel.aborted(.canceled)
dismiss()
}, label: {
Image(systemName: "xmark")
.font(.title2)
.foregroundColor(.white.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
})
.accessibilityHint(.init(L10n.closeLabel))
Group {
Text(title)
.padding(.top)
.font(.title2)
Text(description)
.font(.subheadline)
}
.foregroundColor(.white)

if let alternativeOptionLabel {
Button {
viewModel.aborted(.alternativeOptions)
dismiss()
} label: {
Text(alternativeOptionLabel)
.font(.subheadline)
.foregroundColor(.accentColor)
}
.padding(.top)
}
}
.padding()
}

private var cameraBackground: some View {
BarcodeScannerCameraView(model: cameraDataModel)
.ignoresSafeArea()
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity)
.overlay {
Color.black.opacity(0.8)
}
}

private var cameraSquare: some View {
BarcodeScannerCameraView(model: cameraDataModel, shouldStartCamera: false)
.ignoresSafeArea()
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity)
.mask {
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.frame(width: cameraSquareSize, height: cameraSquareSize)
}
.overlay {
ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.stroke(Color.blue, lineWidth: 1)
.frame(width: cameraSquareSize, height: cameraSquareSize)
Button(action: {
toggleFlashlight()
}, label: {
Image(uiImage: flashlightIcon)
.padding()
.background(Color(uiColor: .init(hex: "#384956")))
.mask(Circle())
.offset(x: -22, y: -22)
})
}
}
}

private func toggleFlashlight() {
cameraDataModel.toggleFlashlight()
}
}

#Preview {
BarcodeScannerView(title: "Scan QR-code", description: "Find the code on your device", incomingMessageId: 1)
}

final class BarcodeScannerHostingController: UIHostingController<BarcodeScannerView> {
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
[.portrait]
}
}
45 changes: 45 additions & 0 deletions Sources/App/QRCodeScanner/BarcodeScannerViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation
import Shared

final class BarcodeScannerViewModel: ObservableObject {
enum AbortReason: String {
case canceled
case alternativeOptions = "alternative_options"
}

private let incomingMessageId: Int

init(incomingMessageId: Int) {
self.incomingMessageId = incomingMessageId
}

func scannedCode(_ code: String, format: String) {
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
.done { [weak self] controller in
guard let incomingMessageId = self?.incomingMessageId else { return }
controller
.sendExternalBus(message: .init(
id: incomingMessageId,
command: WebViewExternalBusOutgoingMessage.barCodeScanResult.rawValue,
payload: [
"rawValue": code,
"format": format,
]
))
}
}

func aborted(_ reason: AbortReason) {
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
.done { [weak self] controller in
guard let incomingMessageId = self?.incomingMessageId else { return }
controller.sendExternalBus(message: .init(
id: incomingMessageId,
command: WebViewExternalBusOutgoingMessage.barCodeScanAborted.rawValue,
payload: [
"reason": reason.rawValue,
]
))
}
}
}

0 comments on commit 02a8457

Please sign in to comment.