From f705ce5c14bb768f7790a999912d39d4dfb32d39 Mon Sep 17 00:00:00 2001
From: Dariusz Rybicki <dariusz@elixxir.io>
Date: Wed, 14 Sep 2022 01:34:35 +0200
Subject: [PATCH] Prototype chat UI implementation

---
 Examples/xx-messenger/Package.swift           |   1 +
 .../SharedUI/GeometryReaderViewModifier.swift | 103 +++++++++
 .../SharedUI/ToolbarViewModifier.swift        | 213 ++++++++++++++++++
 .../Sources/ChatFeature/ChatFeature.swift     |  29 ++-
 .../Sources/ChatFeature/ChatView.swift        | 100 +++++++-
 5 files changed, 441 insertions(+), 5 deletions(-)
 create mode 100644 Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift
 create mode 100644 Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift

diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift
index 2d5fbb9f..15e3c9a8 100644
--- a/Examples/xx-messenger/Package.swift
+++ b/Examples/xx-messenger/Package.swift
@@ -106,6 +106,7 @@ let package = Package(
     .target(
       name: "ChatFeature",
       dependencies: [
+        .target(name: "AppCore"),
         .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
       ],
       swiftSettings: swiftSettings
diff --git a/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift b/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift
new file mode 100644
index 00000000..976b1ffe
--- /dev/null
+++ b/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift
@@ -0,0 +1,103 @@
+// MIT License
+//
+// Copyright (c) 2022 Dariusz Rybicki Darrarski
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// Source: https://github.com/darrarski/swiftui-tabs-view/blob/be6865324ed9651c22df36540f932c10ab9c7c34/Sources/SwiftUITabsView/GeometryReaderViewModifier.swift
+
+import SwiftUI
+
+extension View {
+  func geometryReader<Geometry: Codable>(
+    geometry: @escaping (GeometryProxy) -> Geometry,
+    onChange: @escaping (Geometry) -> Void
+  ) -> some View {
+    modifier(GeometryReaderViewModifier(
+      geometry: geometry,
+      onChange: onChange
+    ))
+  }
+}
+
+struct GeometryReaderViewModifier<Geometry: Codable>: ViewModifier {
+  var geometry: (GeometryProxy) -> Geometry
+  var onChange: (Geometry) -> Void
+
+  func body(content: Content) -> some View {
+    content
+      .background {
+        GeometryReader { geometryProxy in
+          Color.clear
+            .preference(key: GeometryPreferenceKey.self, value: {
+              let geometry = self.geometry(geometryProxy)
+              let data = try? JSONEncoder().encode(geometry)
+              return data
+            }())
+            .onPreferenceChange(GeometryPreferenceKey.self) { data in
+              if let data = data,
+                 let geomerty = try? JSONDecoder().decode(Geometry.self, from: data)
+              {
+                onChange(geomerty)
+              }
+            }
+        }
+      }
+  }
+}
+
+struct GeometryPreferenceKey: PreferenceKey {
+  static var defaultValue: Data? = nil
+
+  static func reduce(value: inout Data?, nextValue: () -> Data?) {
+    value = nextValue()
+  }
+}
+
+#if DEBUG
+struct GeometryReaderModifier_Previews: PreviewProvider {
+  struct Preview: View {
+    @State var size: CGSize = .zero
+
+    var body: some View {
+      VStack {
+        Text("Hello, World!")
+          .font(.largeTitle)
+          .background(Color.accentColor.opacity(0.15))
+          .geometryReader(
+            geometry: \.size,
+            onChange: { size = $0 }
+          )
+
+        Text("\(Int(size.width.rounded())) x \(Int(size.height.rounded()))")
+          .font(.caption)
+          .frame(width: size.width, height: size.height)
+          .background(Color.accentColor.opacity(0.15))
+      }
+    }
+  }
+
+  static var previews: some View {
+    Preview()
+#if os(macOS)
+      .frame(width: 640, height: 480)
+#endif
+  }
+}
+#endif
diff --git a/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift b/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift
new file mode 100644
index 00000000..23d709d8
--- /dev/null
+++ b/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift
@@ -0,0 +1,213 @@
+// MIT License
+//
+// Copyright (c) 2022 Dariusz Rybicki Darrarski
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// Source: https://github.com/darrarski/swiftui-tabs-view/blob/be6865324ed9651c22df36540f932c10ab9c7c34/Sources/SwiftUITabsView/ToolbarViewModifier.swift
+
+import SwiftUI
+
+/// Describes position of the toolbar.
+public enum ToolbarPosition: Equatable {
+  /// Bar positioned above the content.
+  case top
+
+  /// Tabs bar positioned below the content.
+  case bottom
+
+  var verticalEdge: VerticalEdge {
+    switch self {
+    case .top: return .top
+    case .bottom: return .bottom
+    }
+  }
+
+  var frameAlignment: Alignment {
+    switch self {
+    case .top: return .top
+    case .bottom: return .bottom
+    }
+  }
+}
+
+struct ToolbarPositionKey: EnvironmentKey {
+  static var defaultValue: ToolbarPosition = .bottom
+}
+
+extension EnvironmentValues {
+  var toolbarPosition: ToolbarPosition {
+    get { self[ToolbarPositionKey.self] }
+    set { self[ToolbarPositionKey.self] = newValue }
+  }
+}
+
+extension View {
+  public func toolbar<Bar: View>(
+    position: ToolbarPosition = .bottom,
+    ignoresKeyboard: Bool = true,
+    frameChangeAnimation: Animation? = .default,
+    @ViewBuilder bar: @escaping () -> Bar
+  ) -> some View {
+    modifier(ToolbarViewModifier(
+      ignoresKeyboard: ignoresKeyboard,
+      frameChangeAnimation: frameChangeAnimation,
+      bar: bar
+    ))
+    .environment(\.toolbarPosition, position)
+  }
+}
+
+struct ToolbarViewModifier<Bar: View>: ViewModifier {
+  init(
+    ignoresKeyboard: Bool = true,
+    frameChangeAnimation: Animation? = .default,
+    @ViewBuilder bar: @escaping () -> Bar
+  ) {
+    self.ignoresKeyboard = ignoresKeyboard
+    self.frameChangeAnimation = frameChangeAnimation
+    self.bar = bar
+  }
+
+  var ignoresKeyboard: Bool
+  var frameChangeAnimation: Animation?
+  var bar: () -> Bar
+
+  @Environment(\.toolbarPosition) var position
+  @State var contentFrame: CGRect?
+  @State var toolbarFrame: CGRect?
+  @State var toolbarSafeAreaInset: CGSize = .zero
+
+  var keyboardSafeAreaEdges: Edge.Set {
+    guard ignoresKeyboard else { return [] }
+    switch position {
+    case .top: return .top
+    case .bottom: return .bottom
+    }
+  }
+
+  func body(content: Content) -> some View {
+    ZStack {
+      content
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .toolbarSafeAreaInset()
+        .geometryReader(
+          geometry: { $0.frame(in: .global) },
+          onChange: { frame in
+            withAnimation(contentFrame == nil ? .none : frameChangeAnimation) {
+              contentFrame = frame
+              toolbarSafeAreaInset = makeToolbarSafeAreaInset()
+            }
+          }
+        )
+
+      bar()
+        .geometryReader(
+          geometry: { $0.frame(in: .global) },
+          onChange: { frame in
+            withAnimation(toolbarFrame == nil ? .none : frameChangeAnimation) {
+              toolbarFrame = frame
+              toolbarSafeAreaInset = makeToolbarSafeAreaInset()
+            }
+          }
+        )
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.frameAlignment)
+        .ignoresSafeArea(.keyboard, edges: keyboardSafeAreaEdges)
+    }
+    .environment(\.toolbarSafeAreaInset, toolbarSafeAreaInset)
+  }
+
+  func makeToolbarSafeAreaInset() -> CGSize {
+    guard let contentFrame = contentFrame,
+          let toolbarFrame = toolbarFrame
+    else { return .zero }
+
+    var size = contentFrame.intersection(toolbarFrame).size
+    size.width = max(0, size.width)
+    size.height = max(0, size.height)
+
+    return size
+  }
+}
+
+struct ToolbarSafeAreaInsetKey: EnvironmentKey {
+  static var defaultValue: CGSize = .zero
+}
+
+extension EnvironmentValues {
+  var toolbarSafeAreaInset: CGSize {
+    get { self[ToolbarSafeAreaInsetKey.self] }
+    set { self[ToolbarSafeAreaInsetKey.self] = newValue }
+  }
+}
+
+struct ToolbarSafeAreaInsetViewModifier: ViewModifier {
+  @Environment(\.toolbarPosition) var position
+  @Environment(\.toolbarSafeAreaInset) var toolbarSafeAreaInset
+
+  func body(content: Content) -> some View {
+    content
+      .safeAreaInset(edge: position.verticalEdge) {
+        Color.clear.frame(
+          width: toolbarSafeAreaInset.width,
+          height: toolbarSafeAreaInset.height
+        )
+      }
+  }
+}
+
+extension View {
+  /// Add safe area inset for toolbar.
+  ///
+  /// Use this modifier if your content is embedded in `NavigationView`.
+  /// Apply it on the content inside the `NavigationView`.
+  ///
+  /// - Returns: View with additional safe area insets matching the toolbar.
+  public func toolbarSafeAreaInset() -> some View {
+    modifier(ToolbarSafeAreaInsetViewModifier())
+  }
+}
+
+#if DEBUG
+struct ToolbarViewModifier_Previews: PreviewProvider {
+  static var previews: some View {
+    ScrollView {
+      VStack(alignment: .leading, spacing: 0) {
+        ForEach(1..<21) { row in
+          VStack(alignment: .leading, spacing: 0) {
+            Text("Row #\(row)")
+            TextField("Text", text: .constant(""))
+          }
+          .padding()
+          .background(Color.accentColor.opacity(row % 2 == 0 ? 0.1 : 0.15))
+        }
+      }
+    }
+    .toolbar(ignoresKeyboard: true) {
+      Text("Bottom Bar")
+        .padding()
+        .frame(maxWidth: .infinity)
+        .background(.ultraThinMaterial)
+    }
+#if os(macOS)
+    .frame(width: 640, height: 480)
+#endif
+  }
+}
+#endif
diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift
index d95eabce..1b050a0a 100644
--- a/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift
+++ b/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift
@@ -7,11 +7,38 @@ public struct ChatState: Equatable, Identifiable {
     case contact(Data)
   }
 
-  public init(id: ID) {
+  public struct Message: Equatable, Identifiable {
+    public init(
+      id: Data,
+      date: Date,
+      senderId: Data,
+      text: String
+    ) {
+      self.id = id
+      self.date = date
+      self.senderId = senderId
+      self.text = text
+    }
+
+    public var id: Data
+    public var date: Date
+    public var senderId: Data
+    public var text: String
+  }
+
+  public init(
+    id: ID,
+    myContactId: Data? = nil,
+    messages: IdentifiedArrayOf<Message> = []
+  ) {
     self.id = id
+    self.myContactId = myContactId
+    self.messages = messages
   }
 
   public var id: ID
+  public var myContactId: Data?
+  public var messages: IdentifiedArrayOf<Message>
 }
 
 public enum ChatAction: Equatable {
diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift
index d3016743..0d2be0f0 100644
--- a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift
+++ b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift
@@ -1,3 +1,4 @@
+import AppCore
 import ComposableArchitecture
 import SwiftUI
 
@@ -9,13 +10,89 @@ public struct ChatView: View {
   let store: Store<ChatState, ChatAction>
 
   struct ViewState: Equatable {
-    init(state: ChatState) {}
+    var myContactId: Data?
+    var messages: IdentifiedArrayOf<ChatState.Message>
+
+    init(state: ChatState) {
+      myContactId = state.myContactId
+      messages = state.messages
+    }
   }
 
   public var body: some View {
     WithViewStore(store, observe: ViewState.init) { viewStore in
-      Text("ChatView")
-        .task { viewStore.send(.start) }
+      ScrollView {
+        LazyVStack {
+          ForEach(viewStore.messages) { message in
+            MessageView(
+              message: message,
+              myContactId: viewStore.myContactId
+            )
+          }
+        }
+      }
+      .toolbar(
+        position: .bottom,
+        ignoresKeyboard: true,
+        frameChangeAnimation: .default
+      ) {
+        VStack(spacing: 0) {
+          Divider()
+          HStack {
+            TextField("Text", text: .constant(""))
+              .textFieldStyle(.roundedBorder)
+
+            Button {
+
+            } label: {
+              Image(systemName: "paperplane.fill")
+            }
+            .buttonStyle(.borderedProminent)
+          }
+          .padding()
+        }
+        .background(Material.regularMaterial)
+      }
+      .navigationTitle("Chat")
+      .task { viewStore.send(.start) }
+      .toolbarSafeAreaInset()
+    }
+  }
+
+  struct MessageView: View {
+    var message: ChatState.Message
+    var myContactId: Data?
+
+    var alignment: Alignment {
+      message.senderId == myContactId ? .trailing : .leading
+    }
+
+    var backgroundColor: Color {
+      message.senderId == myContactId ? Color.blue : Color.gray.opacity(0.5)
+    }
+
+    var textColor: Color? {
+      message.senderId == myContactId ? Color.white : nil
+    }
+
+    var body: some View {
+      VStack {
+        Text("\(message.date.formatted())")
+          .foregroundColor(.secondary)
+          .font(.footnote)
+          .frame(maxWidth: .infinity, alignment: alignment)
+
+        Text(message.text)
+          .foregroundColor(textColor)
+          .padding(.horizontal, 16)
+          .padding(.vertical, 8)
+          .background {
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
+              .fill(backgroundColor)
+          }
+          .frame(maxWidth: .infinity, alignment: alignment)
+      }
+      .padding(.horizontal)
     }
   }
 }
@@ -26,7 +103,22 @@ public struct ChatView_Previews: PreviewProvider {
     NavigationView {
       ChatView(store: Store(
         initialState: ChatState(
-          id: .contact("contact-id".data(using: .utf8)!)
+          id: .contact("contact-id".data(using: .utf8)!),
+          myContactId: "my-contact-id".data(using: .utf8)!,
+          messages: [
+            .init(
+              id: "message-1-id".data(using: .utf8)!,
+              date: Date(),
+              senderId: "contact-id".data(using: .utf8)!,
+              text: "Hello!"
+            ),
+            .init(
+              id: "message-2-id".data(using: .utf8)!,
+              date: Date(),
+              senderId: "my-contact-id".data(using: .utf8)!,
+              text: "Hi!"
+            ),
+          ]
         ),
         reducer: .empty,
         environment: ()
-- 
GitLab