Skip to content

Commit

Permalink
Replace full screen state view by in-row state view while running wat… (
Browse files Browse the repository at this point in the history
  • Loading branch information
bgoncal committed May 24, 2024
1 parent 979b984 commit dce8d68
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 113 deletions.
24 changes: 24 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,8 @@
421B1C1D2BD65C04001ED18C /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421B1C1B2BD65BFA001ED18C /* View+ConditionalModifier.swift */; };
42266B112B740E4C00E94A71 /* BarcodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */; };
42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */; };
423E95C52BFF2F5E00C2094A /* WatchActionButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423E95C32BFF2ED000C2094A /* WatchActionButtonView.swift */; };
423E95C92BFF314F00C2094A /* MockWatchHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423E95C72BFF314C00C2094A /* MockWatchHomeViewModel.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 @@ -1647,6 +1649,8 @@
421B1C1B2BD65BFA001ED18C /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
42266B102B740E4C00E94A71 /* BarcodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerView.swift; sourceTree = "<group>"; };
42266B242B7A4BA900E94A71 /* BarcodeScannerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerViewModel.swift; sourceTree = "<group>"; };
423E95C32BFF2ED000C2094A /* WatchActionButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchActionButtonView.swift; sourceTree = "<group>"; };
423E95C72BFF314C00C2094A /* MockWatchHomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWatchHomeViewModel.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 @@ -3198,6 +3202,22 @@
path = Widgets;
sourceTree = "<group>";
};
423E95C22BFF2EC500C2094A /* Views */ = {
isa = PBXGroup;
children = (
423E95C32BFF2ED000C2094A /* WatchActionButtonView.swift */,
);
path = Views;
sourceTree = "<group>";
};
423E95C62BFF314200C2094A /* Mocks */ = {
isa = PBXGroup;
children = (
423E95C72BFF314C00C2094A /* MockWatchHomeViewModel.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
425573C52B55729E00145217 /* Servers */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3352,6 +3372,8 @@
42C373B62BC55C3A00898990 /* Home */ = {
isa = PBXGroup;
children = (
423E95C62BFF314200C2094A /* Mocks */,
423E95C22BFF2EC500C2094A /* Views */,
42C373B02BC536AA00898990 /* WatchHomeView.swift */,
42C373B42BC53B1400898990 /* WatchHomeViewModel.swift */,
);
Expand Down Expand Up @@ -6408,7 +6430,9 @@
B6CC5D962159D10E00833E5D /* ExtensionDelegate.swift in Sources */,
110D74CA2640E0DF00406078 /* NotificationSubControllerMedia.swift in Sources */,
B672AB582216B5E000175465 /* Date+ComplicationDivination.swift in Sources */,
423E95C92BFF314F00C2094A /* MockWatchHomeViewModel.swift in Sources */,
42C373B22BC5382900898990 /* HostingController.swift in Sources */,
423E95C52BFF2F5E00C2094A /* WatchActionButtonView.swift in Sources */,
B6CC5D982159D10E00833E5D /* ComplicationController.swift in Sources */,
11169B7C262BDE80005EF90A /* DynamicNotificationController.swift in Sources */,
11684B7A263F994600B48EC3 /* NotificationSubControllerMJPEG.swift in Sources */,
Expand Down
25 changes: 25 additions & 0 deletions Sources/Extensions/Watch/Home/Mocks/MockWatchHomeViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#if DEBUG
import Foundation

final class MockWatchHomeViewModel: WatchHomeViewModelProtocol {
@Published var actions: [WatchActionItem] = []
@Published var state: WatchHomeViewState = .idle

func runActionId(_ actionId: String, completion: @escaping (Bool) -> Void) {}

func onAppear() {
actions = [
.init(
id: "1",
name: "Hello",
iconName: "ab_testing",
backgroundColor: "#34eba8",
iconColor: "#4479b3",
textColor: "#4479b3"
),
]
}

func onDisappear() {}
}
#endif
93 changes: 93 additions & 0 deletions Sources/Extensions/Watch/Home/Views/WatchActionButtonView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Shared
import SwiftUI

struct WatchActionButtonView<ViewModel>: View where ViewModel: WatchHomeViewModelProtocol {
enum ActionState {
case idle
case loading
case success
case failure
}

@EnvironmentObject private var homeViewModel: ViewModel

let action: WatchActionItem

@State private var state: ActionState = .idle

var body: some View {
content
.onChange(of: state) { newValue in
// On watchOS 10 this can be replaced by '.sensoryFeedback' modifier
let currentDevice = WKInterfaceDevice.current()
switch newValue {
case .success:
currentDevice.play(.success)
case .failure:
currentDevice.play(.failure)
case .loading:
currentDevice.play(.click)
default:
break
}
}
}

@ViewBuilder
private var content: some View {
Button {
state = .loading
homeViewModel.runActionId(action.id) { success in
state = success ? .success : .failure
resetState()
}
} label: {
HStack(spacing: Spaces.one) {
iconToDisplay
.animation(.easeInOut, value: state)
Text(action.name)
.foregroundStyle(Color(uiColor: .init(hex: action.textColor)))
}
}
.disabled(state != .idle)
.listRowBackground(
Color(uiColor: .init(hex: action.backgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 12))
)
}

private func resetState() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
state = .idle
}
}

private var iconToDisplay: some View {
VStack {
switch state {
case .idle:
Image(uiImage: MaterialDesignIcons(named: action.iconName).image(
ofSize: .init(width: 24, height: 24),
color: .init(hex: action.iconColor)
))
case .loading:
ProgressView()
.progressViewStyle(.circular)
.frame(width: 24, height: 24)
.tint(.black)
.shadow(color: .white, radius: 10)
case .success:
makeActionImage(iconName: MaterialDesignIcons.checkIcon.name)
case .failure:
makeActionImage(iconName: MaterialDesignIcons.closeThickIcon.name)
}
}
}

private func makeActionImage(iconName: String) -> some View {
Image(uiImage: MaterialDesignIcons(named: iconName).image(
ofSize: .init(width: 24, height: 24),
color: .init(hex: action.iconColor)
))
}
}
83 changes: 2 additions & 81 deletions Sources/Extensions/Watch/Home/WatchHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ struct WatchHomeView<ViewModel>: View where ViewModel: WatchHomeViewModelProtoco
ZStack {
list
noActionsView
stateView
}
.onAppear {
viewModel.onAppear()
Expand All @@ -28,32 +27,6 @@ struct WatchHomeView<ViewModel>: View where ViewModel: WatchHomeViewModelProtoco
}
}

@ViewBuilder
private var stateView: some View {
VStack {
switch viewModel.state {
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success:
Image(uiImage: MaterialDesignIcons.checkCircleIcon.image(ofSize: stateIconSize, color: stateIconColor))
.onAppear {
interfaceDevice.play(.success)
}
case .failure:
Image(uiImage: MaterialDesignIcons.closeIcon.image(ofSize: stateIconSize, color: stateIconColor))
.onAppear {
interfaceDevice.play(.failure)
}
case .idle:
EmptyView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(stateViewBackground)
.opacity(viewModel.state != .idle ? 1 : 0)
}

private var stateViewBackground: some ShapeStyle {
if #available(watchOS 10, *) {
return .regularMaterial
Expand All @@ -64,22 +37,8 @@ struct WatchHomeView<ViewModel>: View where ViewModel: WatchHomeViewModelProtoco

private var list: some View {
List(viewModel.actions, id: \.id) { action in
Button {
viewModel.runActionId(action.id)
} label: {
HStack(spacing: Spaces.one) {
Image(uiImage: MaterialDesignIcons(named: action.iconName).image(
ofSize: .init(width: 24, height: 24),
color: .init(hex: action.iconColor)
))
Text(action.name)
.foregroundStyle(Color(uiColor: .init(hex: action.textColor)))
}
}
.listRowBackground(
Color(uiColor: .init(hex: action.backgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 12))
)
WatchActionButtonView<ViewModel>(action: action)
.environmentObject(viewModel)
}
.animation(.easeInOut, value: viewModel.actions)
}
Expand All @@ -96,42 +55,4 @@ struct WatchHomeView<ViewModel>: View where ViewModel: WatchHomeViewModelProtoco
#Preview {
WatchHomeView(viewModel: MockWatchHomeViewModel())
}

final class MockWatchHomeViewModel: WatchHomeViewModelProtocol {
func runActionId(_ actionId: String) {
DispatchQueue.main.async {
self.state = .loading
}

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.state = .success
}

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.state = .failure
}

DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.state = .idle
}
}

@Published var actions: [WatchActionItem] = []
@Published var state: WatchHomeViewState = .idle

func onAppear() {
actions = [
.init(
id: "1",
name: "Hello",
iconName: "ab_testing",
backgroundColor: "#34eba8",
iconColor: "#4479b3",
textColor: "#4479b3"
),
]
}

func onDisappear() {}
}
#endif
42 changes: 10 additions & 32 deletions Sources/Extensions/Watch/Home/WatchHomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ struct WatchActionItem: Equatable {

protocol WatchHomeViewModelProtocol: ObservableObject {
var actions: [WatchActionItem] { get set }
var state: WatchHomeViewState { get set }
func onAppear()
func onDisappear()
func runActionId(_ actionId: String)
func runActionId(_ actionId: String, completion: @escaping (Bool) -> Void)
}

enum WatchHomeViewState {
Expand All @@ -35,11 +34,6 @@ final class WatchHomeViewModel: WatchHomeViewModelProtocol {
}

@Published var actions: [WatchActionItem] = []
@Published var state: WatchHomeViewState = .idle {
didSet {
resetStateToIdleIfNeeded()
}
}

private var actionsToken: NotificationToken?
private var realmActions: [Action] = []
Expand All @@ -52,13 +46,14 @@ final class WatchHomeViewModel: WatchHomeViewModelProtocol {
actionsToken?.invalidate()
}

func runActionId(_ actionId: String) {
guard let selectedAction = realmActions.first(where: { $0.ID == actionId }) else { return }
func runActionId(_ actionId: String, completion: @escaping (Bool) -> Void) {
guard let selectedAction = realmActions.first(where: { $0.ID == actionId }) else {
completion(false)
return
}

Current.Log.verbose("Selected action id: \(actionId)")

setState(.loading)

firstly { () -> Promise<Void> in
Promise { seal in
guard Communicator.shared.currentReachability == .immediatelyReachable else {
Expand Down Expand Up @@ -93,17 +88,11 @@ final class WatchHomeViewModel: WatchHomeViewModelProtocol {

Current.Log.error("recovering error \(error) by trying locally")
return Current.api(for: server).HandleAction(actionID: selectedAction.ID, source: .Watch)
}.done { [weak self] in
self?.setState(.success)
}.catch { [weak self] err in
}.done {
completion(true)
}.catch { err in
Current.Log.error("Error during action event fire: \(err)")
self?.setState(.failure)
}
}

private func setState(_ state: WatchHomeViewState) {
DispatchQueue.main.async { [weak self] in
self?.state = state
completion(false)
}
}

Expand All @@ -129,17 +118,6 @@ final class WatchHomeViewModel: WatchHomeViewModelProtocol {
}
}
}

private func resetStateToIdleIfNeeded() {
switch state {
case .success, .failure:
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.state = .idle
}
default:
break
}
}
}

private extension Action {
Expand Down

0 comments on commit dce8d68

Please sign in to comment.