diff --git a/GliaWidgets/Public/Glia/Glia+EntryWidget.swift b/GliaWidgets/Public/Glia/Glia+EntryWidget.swift index d63b57ee0..2f1dd8f05 100644 --- a/GliaWidgets/Public/Glia/Glia+EntryWidget.swift +++ b/GliaWidgets/Public/Glia/Glia+EntryWidget.swift @@ -10,6 +10,7 @@ extension Glia { /// - `EntryWidget` instance. public func getEntryWidget(queueIds: [String]) throws -> EntryWidget { EntryWidget( + queueIds: queueIds, environment: .init( queuesMonitor: environment.queuesMonitor, engagementLauncher: try getEngagementLauncher(queueIds: queueIds), diff --git a/GliaWidgets/Sources/EntryWidget/CustomPresentationController.swift b/GliaWidgets/Sources/EntryWidget/CustomPresentationController.swift index 0a1761b6d..a88cc3ce7 100644 --- a/GliaWidgets/Sources/EntryWidget/CustomPresentationController.swift +++ b/GliaWidgets/Sources/EntryWidget/CustomPresentationController.swift @@ -1,7 +1,7 @@ import UIKit final class CustomPresentationController: UIPresentationController { - private let height: CGFloat + private var height: CGFloat private let cornerRadius: CGFloat private let dimmingView: UIView = { let view = UIView() @@ -82,3 +82,12 @@ final class CustomPresentationController: UIPresentationController { } } } + +extension CustomPresentationController { + func updateHeight(to newHeight: CGFloat) { + self.height = newHeight + UIView.animate(withDuration: 0.3) { + self.presentedView?.frame = self.frameOfPresentedViewInContainerView + } + } +} diff --git a/GliaWidgets/Sources/EntryWidget/EntryWidget.Channel.swift b/GliaWidgets/Sources/EntryWidget/EntryWidget.Channel.swift index 8ae4ebd84..3ed94fffc 100644 --- a/GliaWidgets/Sources/EntryWidget/EntryWidget.Channel.swift +++ b/GliaWidgets/Sources/EntryWidget/EntryWidget.Channel.swift @@ -2,10 +2,10 @@ import GliaCoreSDK import SwiftUI extension EntryWidget { - enum Channel { - case chat - case audio + enum MediaTypeItem: Int { case video + case audio + case chat case secureMessaging var headline: String { @@ -17,7 +17,7 @@ extension EntryWidget { case .video: return "Video" case .secureMessaging: - return "SecureMessaging" + return "Secure Messaging" } } @@ -49,7 +49,7 @@ extension EntryWidget { } } -extension EntryWidget.Channel { +extension EntryWidget.MediaTypeItem { init?(mediaType: MediaType) { switch mediaType { case .audio: diff --git a/GliaWidgets/Sources/EntryWidget/EntryWidget.swift b/GliaWidgets/Sources/EntryWidget/EntryWidget.swift index 6c65d9754..876b4746f 100644 --- a/GliaWidgets/Sources/EntryWidget/EntryWidget.swift +++ b/GliaWidgets/Sources/EntryWidget/EntryWidget.swift @@ -7,9 +7,10 @@ import SwiftUI public final class EntryWidget: NSObject { private var hostedViewController: UIViewController? private var embeddedView: UIView? - private var queueIds: [String] = [] + private var queueIds: [String] + + @Published private var viewState: ViewState = .loading - @Published private var channels: [Channel] = [] private let sizeConstraints: SizeConstraints = .init( singleCellHeight: 72, singleCellIconSize: 24, @@ -24,7 +25,11 @@ public final class EntryWidget: NSObject { private var cancellables = CancelBag() - init(environment: Environment) { + init( + queueIds: [String], + environment: Environment + ) { + self.queueIds = queueIds self.environment = environment super.init() @@ -55,14 +60,75 @@ public final class EntryWidget: NSObject { } } +// MARK: - Private methods private extension EntryWidget { + func handleQueuesMonitorUpdates(state: QueuesMonitor.State) { + switch state { + case .idle: + viewState = .loading + case .updated(let queues): + let availableMediaTypes = resolveAvailableMediaTypes(from: queues) + if availableMediaTypes.isEmpty { + viewState = .offline + } else { + viewState = .mediaTypes(availableMediaTypes) + } + case .failed: + viewState = .error + print("Failed to update queues") + } + } + + func resolveAvailableMediaTypes(from queues: [Queue]) -> [MediaTypeItem] { + var availableMediaTypes: Set = [] + + queues.forEach { queue in + queue.state.media.forEach { mediaType in + if let mediaTypeItem = MediaTypeItem(mediaType: mediaType) { + availableMediaTypes.insert(mediaTypeItem) + } + } + } + return Array(availableMediaTypes).sorted(by: { $0.rawValue < $1.rawValue }) + } + + func mediaTypeSelected(_ mediaTypeItem: MediaTypeItem) { + do { + switch mediaTypeItem { + case .chat: + try environment.engagementLauncher.startChat() + case .audio: + try environment.engagementLauncher.startAudioCall() + case .video: + try environment.engagementLauncher.startVideoCall() + case .secureMessaging: + try environment.engagementLauncher.startSecureMessaging() + } + } catch { + viewState = .error + } + } + + func makeViewModel(showHeader: Bool) -> EntryWidgetView.Model { + let viewModel = EntryWidgetView.Model( + theme: environment.theme, + showHeader: showHeader, + sizeConstraints: sizeConstraints, + viewStatePublisher: $viewState, + mediaTypeSelected: mediaTypeSelected(_:) + ) + + viewModel.retryMonitoring = { [weak self] in + self?.viewState = .loading + self?.environment.queuesMonitor.startMonitoring(queuesIds: self?.queueIds ?? []) + } + + return viewModel + } + func showView(in parentView: UIView) { parentView.subviews.forEach { $0.removeFromSuperview() } - let model = makeViewModel( - showHeader: false, - channels: channels, - selection: channelSelected(_:) - ) + let model = makeViewModel(showHeader: false) let view = makeView(model: model) let hostingController = UIHostingController(rootView: view) @@ -80,11 +146,7 @@ private extension EntryWidget { } func showSheet(in parentViewController: UIViewController) { - let model = makeViewModel( - showHeader: true, - channels: channels, - selection: channelSelected(_:) - ) + let model = makeViewModel(showHeader: true) let view = makeView(model: model) let hostingController = UIHostingController(rootView: view) @@ -106,10 +168,7 @@ private extension EntryWidget { if #available(iOS 16.0, *) { guard let sheet = hostingController.sheetPresentationController else { return } let smallDetent: UISheetPresentationController.Detent = .custom { _ in - return self.calculateHeight( - channels: self.channels, - sizeConstraints: self.sizeConstraints - ) + return self.calculateHeight() } sheet.detents = [smallDetent] sheet.prefersScrollingExpandsWhenScrolledToEdge = true @@ -121,53 +180,79 @@ private extension EntryWidget { parentViewController.present(hostingController, animated: true, completion: nil) hostedViewController = hostingController + + observeViewState() } - func makeView(model: EntryWidgetView.Model) -> EntryWidgetView { - .init(model: model) + func observeViewState() { + $viewState + .receive(on: RunLoop.main) + .sink { [weak self] state in + self?.viewStateDidChange(state) + } + .store(in: &cancellables) } - func makeViewModel( - showHeader: Bool, - channels: [Channel], - selection: @escaping (Channel) throws -> Void - ) -> EntryWidgetView.Model { - .init( - theme: environment.theme, - showHeader: showHeader, - sizeConstrainsts: sizeConstraints, - channels: $channels, - channelSelected: selection - ) + func viewStateDidChange(_ state: ViewState) { + let newHeight = calculateHeight() + + if #available(iOS 16.0, *) { + if let sheet = hostedViewController?.sheetPresentationController { + let smallDetent = UISheetPresentationController.Detent.custom { _ in + return newHeight + } + sheet.detents = [smallDetent] + sheet.animateChanges { + sheet.selectedDetentIdentifier = nil + } + } + } else { + if let customPresentationController = hostedViewController?.presentationController as? CustomPresentationController { + customPresentationController.updateHeight(to: newHeight) + } + } } - func calculateHeight( - channels: [Channel], - sizeConstraints: SizeConstraints - ) -> CGFloat { + func calculateHeight() -> CGFloat { + var mediaTypesCount: Int + switch viewState { + case .mediaTypes(let mediaTypes): + mediaTypesCount = mediaTypes.count + case .loading, .error, .offline: + // 4 gives the desired fixed size + mediaTypesCount = 4 + } var appliedHeight: CGFloat = 0 - appliedHeight += sizeConstraints.sheetHeaderHeight - channels.forEach { _ in - appliedHeight += sizeConstraints.singleCellHeight - appliedHeight += sizeConstraints.dividerHeight - } + appliedHeight += CGFloat(mediaTypesCount) * (sizeConstraints.singleCellHeight + sizeConstraints.dividerHeight) appliedHeight += sizeConstraints.poweredByContainerHeight return appliedHeight } + + func makeView(model: EntryWidgetView.Model) -> EntryWidgetView { + .init(model: model) + } } +// MARK: - View State +extension EntryWidget { + enum ViewState { + case loading + case mediaTypes([MediaTypeItem]) + case offline + case error + } +} + +// MARK: - UIViewControllerTransitioningDelegate extension EntryWidget: UIViewControllerTransitioningDelegate { public func presentationController( forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController ) -> UIPresentationController? { - let height = calculateHeight( - channels: channels, - sizeConstraints: sizeConstraints - ) + let height = calculateHeight() return CustomPresentationController( presentedViewController: presented, presenting: presenting, @@ -176,46 +261,3 @@ extension EntryWidget: UIViewControllerTransitioningDelegate { ) } } - -private extension EntryWidget { - func handleQueuesMonitorUpdates(state: QueuesMonitor.State) { - switch state { - case .idle: - break - case .updated(let queues): - let availableChannels = resolveAvailableChannels(from: queues) - self.channels = availableChannels - case .failed(let error): - // TODO: Handle error on EntryWidgetView - print(error) - } - } - - func resolveAvailableChannels(from queues: [Queue]) -> [Channel] { - var availableChannels: Set = [] - - queues.forEach { queue in - queue.state.media.forEach { mediaType in - guard let channel = Channel(mediaType: mediaType) else { - return - } - availableChannels.insert(channel) - } - } - // TODO: Add sorting for representing on UI - return Array(availableChannels) - } - - func channelSelected(_ channel: Channel) throws { - switch channel { - case .chat: - try environment.engagementLauncher.startChat() - case .audio: - try environment.engagementLauncher.startAudioCall() - case .video: - try environment.engagementLauncher.startVideoCall() - case .secureMessaging: - try environment.engagementLauncher.startSecureMessaging() - } - } -} diff --git a/GliaWidgets/Sources/EntryWidget/EntryWidgetView.swift b/GliaWidgets/Sources/EntryWidget/EntryWidgetView.swift index 2b2cd2126..9acaf7a3d 100644 --- a/GliaWidgets/Sources/EntryWidget/EntryWidgetView.swift +++ b/GliaWidgets/Sources/EntryWidget/EntryWidgetView.swift @@ -9,8 +9,8 @@ struct EntryWidgetView: View { errorView() case .loading: loadingView() - case .mediaTypes: - mediaTypesView() + case let .mediaTypes(types): + mediaTypesView(types) case .offline: offilineView() } @@ -43,6 +43,7 @@ private extension EntryWidgetView { } .maxSize() .padding(.horizontal) + .applyColorTypeBackground(model.style.backgroundColor) } @ViewBuilder @@ -52,18 +53,19 @@ private extension EntryWidgetView { } @ViewBuilder - func mediaTypesView() -> some View { + func mediaTypesView(_ types: [EntryWidget.MediaTypeItem]) -> some View { VStack(spacing: 0) { if model.showHeader { headerView() } - channelsView() + mediaTypes(types) if model.showPoweredBy { poweredByView() } } .maxSize() .padding(.horizontal) + .applyColorTypeBackground(model.style.backgroundColor) } @ViewBuilder @@ -89,16 +91,17 @@ private extension EntryWidgetView { } .maxSize() .padding(.horizontal) + .applyColorTypeBackground(model.style.backgroundColor) } } // MARK: - View Components private extension EntryWidgetView { @ViewBuilder - func channelsView() -> some View { + func mediaTypes(_ types: [EntryWidget.MediaTypeItem]) -> some View { VStack(spacing: 0) { - ForEach(model.channels.indices, id: \.self) { index in - channelCell(channel: model.channels[index]) + ForEach(types.indices, id: \.self) { index in + mediaTypeCell(mediaType: types[index]) Divider() .height(model.sizeConstraints.dividerHeight) .setColor(model.style.dividerColor) @@ -127,12 +130,12 @@ private extension EntryWidgetView { } @ViewBuilder - func channelCell(channel: EntryWidget.Channel) -> some View { + func mediaTypeCell(mediaType: EntryWidget.MediaTypeItem) -> some View { HStack(spacing: 16) { - icon(channel.image) + icon(mediaType.image) VStack(alignment: .leading, spacing: 2) { - headlineText(channel.headline) - subheadlineText(channel.subheadline) + headlineText(mediaType.headline) + subheadlineText(mediaType.subheadline) } } .maxWidth(alignment: .leading) @@ -140,7 +143,7 @@ private extension EntryWidgetView { .applyColorTypeBackground(model.style.mediaTypeItem.backgroundColor) .contentShape(.rect) .onTapGesture { - model.selectChannel(channel) + model.selectMediaType(mediaType) } } diff --git a/GliaWidgets/Sources/EntryWidget/EntryWidgetViewModel.swift b/GliaWidgets/Sources/EntryWidget/EntryWidgetViewModel.swift index 7ee191cab..d9c718647 100644 --- a/GliaWidgets/Sources/EntryWidget/EntryWidgetViewModel.swift +++ b/GliaWidgets/Sources/EntryWidget/EntryWidgetViewModel.swift @@ -2,13 +2,12 @@ import SwiftUI extension EntryWidgetView { class Model: ObservableObject { - @Published var viewState: ViewState - @Published var channels: [EntryWidget.Channel] = [] + @Published var viewState: EntryWidget.ViewState = .loading let theme: Theme - let channelSelected: (EntryWidget.Channel) throws -> Void + let mediaTypeSelected: (EntryWidget.MediaTypeItem) -> Void let sizeConstraints: EntryWidget.SizeConstraints let showHeader: Bool - + var retryMonitoring: (() -> Void)? var style: EntryWidgetStyle { theme.entryWidgetStyle } @@ -24,42 +23,24 @@ extension EntryWidgetView { init( theme: Theme, showHeader: Bool, - sizeConstrainsts: EntryWidget.SizeConstraints, - channels: Published<[EntryWidget.Channel]>.Publisher, - channelSelected: @escaping (EntryWidget.Channel) throws -> Void + sizeConstraints: EntryWidget.SizeConstraints, + viewStatePublisher: Published.Publisher, + mediaTypeSelected: @escaping (EntryWidget.MediaTypeItem) -> Void ) { self.theme = theme - self.sizeConstraints = sizeConstrainsts self.showHeader = showHeader - self.channelSelected = channelSelected - self.viewState = .mediaTypes + self.sizeConstraints = sizeConstraints + self.mediaTypeSelected = mediaTypeSelected - channels.assign(to: &self.$channels) + viewStatePublisher.assign(to: &$viewState) } - } -} -extension EntryWidgetView.Model { - func selectChannel(_ channel: EntryWidget.Channel) { - do { - try channelSelected(channel) - } catch { - // TODO: Distinguish errors on View if needed - viewState = .error + func selectMediaType(_ mediaTypeItem: EntryWidget.MediaTypeItem) { + mediaTypeSelected(mediaTypeItem) } - } - func onTryAgainTapped() { - // The logic will be added together with EngagementLauncher integration - print("Try again button tapped") - } -} - -extension EntryWidgetView.Model { - enum ViewState { - case error - case loading - case mediaTypes - case offline + func onTryAgainTapped() { + retryMonitoring?() + } } }