Skip to content

Commit

Permalink
[Feature]Collect user feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
ipavlidakis committed May 2, 2024
1 parent a72a667 commit a48ec3e
Show file tree
Hide file tree
Showing 14 changed files with 427 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "feedback-logo.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
Binary file not shown.
2 changes: 1 addition & 1 deletion DemoApp/Sources/Components/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ extension AppEnvironment {
return .detailed
case .debug:
#if targetEnvironment(simulator)
return .detailed
return .simple
#else
return .simple
#endif
Expand Down
70 changes: 70 additions & 0 deletions DemoApp/Sources/Components/Feedback/CallEndedViewModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation
import StreamVideo
import SwiftUI

private struct CallEndedViewModifier<Subview: View>: ViewModifier {

private final class CallEndedViewModifierState: ObservableObject {
@Published var call: Call?
@Published var isPresentingSubview: Bool = false

init() {}
}

private var notificationCenter: NotificationCenter
private var subviewProvider: (Call?) -> Subview

@StateObject private var state: CallEndedViewModifierState = .init()

init(
notificationCenter: NotificationCenter,
@ViewBuilder subviewProvider: @escaping (Call?) -> Subview
) {
self.notificationCenter = notificationCenter
self.subviewProvider = subviewProvider
}

func body(content: Content) -> some View {
content
.sheet(isPresented: $state.isPresentingSubview) {
subviewProvider(state.call)
}
.onReceive(
notificationCenter.publisher(for: .init(CallNotification.callEnded))
) { notification in
guard let call = notification.object as? Call else {
log.warning("Received CallNotification.callEnded but the object isn't a call.")
state.isPresentingSubview = false
return
}

guard state.call?.cId != call.cId else {
return
}

log.debug("Received CallNotification.callEnded for call:\(call.cId)")
state.call = call
state.isPresentingSubview = true
}
}
}

extension View {

@ViewBuilder
public func onCallEnded(
notificationCenter: NotificationCenter = .default,
@ViewBuilder _ content: @escaping (Call?) -> some View
) -> some View {
modifier(
CallEndedViewModifier(
notificationCenter: notificationCenter,
subviewProvider: content
)
)
}
}
155 changes: 155 additions & 0 deletions DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation
import StreamVideo
import StreamVideoSwiftUI
import SwiftUI

@available(iOS 15.0, *)
struct DemoFeedbackView: View {

@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@Injected(\.appearance) private var appearance

@State private var email: String = ""
@State private var comment: String = ""
@State private var rating: Int = 1
@State private var isSubmitting = false

private var call: Call
private var isSubmitEnabled: Bool { !email.isEmpty && !isSubmitting }

init(_ call: Call) {
self.call = call
}

var body: some View {
ScrollView {
VStack(spacing: 32) {
Image(.feedbackLogo)

VStack(spacing: 8) {
Text("How is your call going?")
.font(appearance.fonts.headline)
.foregroundColor(appearance.colors.text)
.lineLimit(1)

Text("All feedback is celebrated!")
.font(appearance.fonts.subheadline)
.foregroundColor(.init(appearance.colors.textLowEmphasis))
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .center)
.multilineTextAlignment(.center)

VStack(spacing: 27) {
VStack(spacing: 16) {
TextField(
"Email Address",
text: $email,
prompt: Text("Email Address *")
)
.textFieldStyle(DemoTextfieldStyle())

DemoTextEditor(text: $comment, placeholder: "Message")
}

HStack {
Text("Rate Quality")
.font(appearance.fonts.body)
.foregroundColor(.init(appearance.colors.textLowEmphasis))
.frame(maxWidth: .infinity, alignment: .leading)

DemoStarRatingView(rating: $rating)
}
}

HStack {
Button {
resignFirstResponder()
openURL(.init(string: "https://getstream.io/video/#contact")!)
} label: {
Text("Contact Us")
}
.frame(maxWidth: .infinity)
.foregroundColor(appearance.colors.text)
.padding(.vertical, 4)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color(appearance.colors.textLowEmphasis), lineWidth: 1))

Button {
resignFirstResponder()
isSubmitting = true
Task {
do {
try await call.collectUserFeedback(
rating: rating,
reason: """
\(email)
\(comment)
"""
)
Task { @MainActor in
dismiss()
}
isSubmitting = false
} catch {
log.error(error)
dismiss()
isSubmitting = false
}
}
} label: {
if isSubmitting {
ProgressView()
} else {
Text("Submit")
}
}
.frame(maxWidth: .infinity)
.foregroundColor(appearance.colors.text)
.padding(.vertical, 4)
.background(isSubmitEnabled ? appearance.colors.accentBlue : appearance.colors.lightGray)
.disabled(!isSubmitEnabled)
.clipShape(Capsule())
}

Spacer()
}
.padding(.horizontal)
}
.withModalNavigationBar(title: "", closeAction: { dismiss() })
}
}

struct DemoStarRatingView: View {
var rating: Binding<Int>

private var range: ClosedRange<Int>

init(
rating: Binding<Int>,
minRating: Int = 1,
maxRating: Int = 5
) {
self.rating = rating
range = minRating...maxRating
}

var body: some View {
HStack {
ForEach(range, id: \.self) { index in
Image(systemName: index <= rating.wrappedValue ? "star.fill" : "star")
.resizable()
.frame(width: 30, height: 30)
.foregroundColor(.yellow)
.onTapGesture {
rating.wrappedValue = index
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ struct DemoModalNavigationBarViewModifier: ViewModifier {
func body(content: Content) -> some View {
VStack(spacing: 0) {
HStack {
Text(title)
.font(fonts.title3)
.fontWeight(.medium)
if !title.isEmpty {
Text(title)
.font(fonts.title3)
.fontWeight(.medium)
}

Spacer()

Expand Down
5 changes: 5 additions & 0 deletions DemoApp/Sources/Views/CallView/DemoCallContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ internal struct DemoCallContainerView: View {
chatViewModel: chatViewModel
)
)
.onCallEnded { call in
if #available(iOS 15.0, *), let call {
DemoFeedbackView(call)
}
}
.onContinueUserActivity(
NSStringFromClass(INStartCallIntent.self),
perform: didContinueUserActivity(_:)
Expand Down
80 changes: 77 additions & 3 deletions DemoApp/Sources/Views/Login/AddUserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ struct AddUserView: View {

@Injected(\.appearance) var appearance
@Environment(\.presentationMode) var presentationMode

@State var name = ""
@State var id = ""

var body: some View {
NavigationView {
ScrollView {
Expand Down Expand Up @@ -46,7 +46,7 @@ struct AddUserView: View {
.navigationTitle("Add a new User")
}
}

private var buttonDisabled: Bool {
name.isEmpty || id.isEmpty
}
Expand All @@ -70,3 +70,77 @@ struct DemoTextfieldStyle: TextFieldStyle {
.clipShape(clipShape)
}
}

struct DemoTextEditor: View {

@Injected(\.appearance) var appearance

var text: Binding<String>
@State var cornerRadius: CGFloat = 8

var placeholder: String

private let notificationCenter: NotificationCenter = .default

@ViewBuilder
private var clipShape: some Shape { RoundedRectangle(cornerRadius: cornerRadius) }

var body: some View {
withPlaceholder {
withClearBackgroundContent
.lineLimit(4)
.padding()
.foregroundColor(
text.wrappedValue == placeholder
? .init(appearance.colors.textLowEmphasis)
: appearance.colors.text
)
.background(Color(appearance.colors.background))
.overlay(clipShape.stroke(Color(appearance.colors.textLowEmphasis), lineWidth: 1))
.clipShape(clipShape)
.frame(height: 100)
}
}

@ViewBuilder
private var withClearBackgroundContent: some View {
if #available(iOS 16.0, *) {
TextEditor(text: text)
.scrollContentBackground(.hidden)
} else {
TextEditor(text: text)
}
}

@ViewBuilder
private func withPlaceholder(
@ViewBuilder content: () -> some View
) -> some View {
content()
.onReceive(
notificationCenter.publisher(for: UIResponder.keyboardWillShowNotification),
perform: { _ in
withAnimation {
if self.text.wrappedValue == placeholder {
self.text.wrappedValue = ""
}
}
}
)
.onReceive(
notificationCenter.publisher(for: UIResponder.keyboardWillHideNotification),
perform: { _ in
withAnimation {
if self.text.wrappedValue.isEmpty {
self.text.wrappedValue = placeholder
}
}
}
)
.onAppear {
if text.wrappedValue.isEmpty {
text.wrappedValue = placeholder
}
}
}
}
Loading

0 comments on commit a48ec3e

Please sign in to comment.