美文网首页
使用AWS构建后端(四) —— Data Store(二)

使用AWS构建后端(四) —— Data Store(二)

作者: 刀客传奇 | 来源:发表于2020-11-17 22:07 被阅读0次

    版本记录

    版本号 时间
    V1.0 2020.11.17 星期二

    前言

    使用Amazon Web Services(AWS)为iOS应用构建后端,可以用来学习很多东西。下面我们就一起学习下如何使用Xcode Server。感兴趣的可以看下面几篇文章。
    1. 使用AWS构建后端(一) —— Authentication & API(一)
    2. 使用AWS构建后端(二) —— Authentication & API(二)
    3. 使用AWS构建后端(三) —— Data Store(一)

    源码

    1. Swift

    首先看下工程组织结构

    下面就是部分源码了

    1. HomeScreen.swift
    
    import SwiftUI
    
    struct HomeScreen: View {
      @ObservedObject private(set) var model: HomeScreenViewModel
      @EnvironmentObject var userSession: UserSession
      @EnvironmentObject var viewModelFactory: ViewModelFactory
    
      var body: some View {
        NavigationView {
          ZStack {
            Color.backgroundColor
              .edgesIgnoringSafeArea(.all)
    
            Loadable(loadingState: model.userPostcodeState, hideContentWhenLoading: true) { postcode in
              if postcode == nil {
                SetPostcodeView(model: model)
              } else {
                ThreadsScreen(
                  model: ThreadsScreenViewModel(userSession: userSession)
                )
              }
            }
          }
        }
      }
    }
    
    struct HomeScreen_Previews: PreviewProvider {
      static var previews: some View {
        let userSession = UserSession()
        let viewModel = HomeScreenViewModel(
          userID: "123",
          user: UserModel(id: "1", username: "Bob", sub: "123", postcode: "SW1A 1AA")
        )
        return HomeScreen(model: viewModel)
          .environmentObject(userSession)
          .environmentObject(ViewModelFactory(userSession: userSession))
      }
    }
    
    2. SetPostcodeView.swift
    
    import SwiftUI
    
    struct SetPostcodeView: View {
      @EnvironmentObject var user: UserSession
      @State var postcode: String = ""
    
      var model: HomeScreenViewModel
    
      var body: some View {
        VStack {
          Text("Save the Nation in Isolation!")
            .italic()
            .padding(.bottom)
          Text(
            """
            This app puts you in touch with those \
            in your neighborhood so you can help \
            each other out. Please let us know your \
            postcode so we can add you to the correct thread.
            """)
            .font(.body)
            .padding(.bottom)
          TextField("Enter your postcode", text: $postcode)
            .padding(.trailing)
            .textFieldStyle(RoundedBorderTextFieldStyle())
          Button(
            action: {
              let sanitisedPostcode = postcode
                .trimmingCharacters(in: .whitespacesAndNewlines)
                .uppercased()
              model.perform(action: .addPostCode(sanitisedPostcode))
            },
            label: {
              Text("Update")
            }
          )
            .disabled(!postcode
              .trimmingCharacters(in: .whitespacesAndNewlines)
              .uppercased()
              .isValidPostcode()
          )
        }
        .padding()
        .keyboardAdaptive()
      }
    }
    
    struct SetPostcodeView_Previews: PreviewProvider {
      static var previews: some View {
        let user = UserModel(id: "0", username: "Bob", sub: "0", postcode: "")
    
        return SetPostcodeView(model: HomeScreenViewModel(userID: "", user: user))
          .environmentObject(UserSession())
      }
    }
    
    3. ThreadsScreen.swift
    
    import SwiftUI
    
    struct ThreadsScreen: View {
      @EnvironmentObject var viewModelFactory: ViewModelFactory
      @EnvironmentObject var userSession: UserSession
      @ObservedObject private(set) var model: ThreadsScreenViewModel
    
      struct SignOutButton: View {
        let userSession: UserSession
    
        var body: some View {
          let rootView = RootView(
            userSession: userSession,
            viewModelFactory: ViewModelFactory(userSession: userSession)
          )
          return NavigationLink(destination: rootView) {
            Button(action: signOut) {
              Text("Sign Out")
            }
          }
        }
    
        func signOut() {
          let authService = AuthenticationService(userSession: userSession)
          authService.signOut()
        }
      }
    
      var body: some View {
        Loadable(loadingState: model.threadListState) { threadList in
          List(threadList) { thread in
            VStack(alignment: .leading) {
              NavigationLink(
                destination: MessagesScreen(model: viewModelFactory.getOrCreateMessagesScreenViewModel(for: thread.id))
              ) {
                Text(thread.name)
              }
            }
          }
          .listStyle(GroupedListStyle())
          .navigationBarTitle(Text("Locations"))
          .navigationBarItems(trailing: SignOutButton(userSession: userSession))
        }
      }
    }
    
    struct ThreadsScreen_Previews: PreviewProvider {
      static var previews: some View {
        let sampleData = [
          ThreadModel(id: "0", name: "SW1A")
        ]
    
        return ThreadsScreen(model:
          ThreadsScreenViewModel(userSession: UserSession(), threads: sampleData)
        )
          .environmentObject(UserSession())
          .environmentObject(ViewModelFactory(userSession: UserSession()))
      }
    }
    
    4. ReplyInputBar.swift
    
    import SwiftUI
    
    struct ReplyInputBar: View {
      @EnvironmentObject var user: UserSession
      @State var reply = CreateReplyInput(body: "", replyMessageId: "")
    
      var model: RepliesScreenViewModel
    
      var body: some View {
        HStack {
          TextField("Offer to help", text: $reply.body)
            .padding(.trailing)
            .textFieldStyle(RoundedBorderTextFieldStyle())
          Button(
            action: {
              reply.replyMessageId = model.messageID
              model.perform(action: .addReply(reply))
              reply.body = ""
            },
            label: {
              Text("Reply")
            }
          )
            .disabled(reply.body.count < 5)
        }
        .padding()
      }
    }
    
    struct ReplyInputBar_Previews: PreviewProvider {
      static var previews: some View {
        let sampleMessage = MessageModel(
          id: "0",
          body: "Help Needed. Can somebody buy me some groceries please",
          authorName: "Lizzie",
          messageThreadId: "0",
          createdAt: Date()
        )
        return ReplyInputBar(
          model: RepliesScreenViewModel(
            userSession: UserSession(),
            messageID: "0",
            message: sampleMessage,
            replies: [
              ReplyModel(id: "0", body: "I can help!", authorName: "Bob", messageId: "0", createdAt: Date()),
              ReplyModel(id: "1", body: "So can I!", authorName: "Andrew", messageId: "0", createdAt: Date()),
              ReplyModel(id: "2", body: "What do you need?", authorName: "Cathy", messageId: "0", createdAt: Date())
            ]
          )
        )
      }
    }
    
    5. RepliesScreen.swift
    
    import SwiftUI
    import Combine
    
    struct RepliesScreen: View {
      @ObservedObject private(set) var model: RepliesScreenViewModel
    
      var body: some View {
        Loadable(loadingState: model.replyListState) { replyList in
          ZStack {
            Color.backgroundColor
              .edgesIgnoringSafeArea(.all)
            VStack(alignment: .leading) {
              MessageDetailsHeader(message: $model.message.wrappedValue)
              Text("Replies")
                .font(.caption)
                .padding(.leading)
              Divider()
              RepliesView(
                repliesList: replyList
              )
              ReplyInputBar(model: model)
            }
            .background(Color.backgroundColor)
            .navigationBarTitle(Text("Replies"))
            .keyboardAdaptive()
          }
        }.onAppear {
          model.perform(action: .fetchReplies)
          model.perform(action: .subscribe)
        }
      }
    }
    
    struct RepliesScreen_Previews: PreviewProvider {
      static var previews: some View {
        let sampleMessage = MessageModel(
          id: "0",
          body: "Help Needed. Can somebody buy me some groceries please",
          authorName: "Lizzie",
          messageThreadId: "0",
          createdAt: Date()
        )
        let sampleData = [
          ReplyModel(id: "0", body: "Sure, what do you need?", authorName: "Charlie", messageId: "0", createdAt: Date()),
          ReplyModel(id: "1", body: "Yup! Give me a call?", authorName: "Andy", messageId: "0", createdAt: Date())
        ]
    
        return RepliesScreen(
          model: RepliesScreenViewModel(
            userSession: UserSession(),
            messageID: "0",
            message: sampleMessage,
            replies: sampleData
          )
        )
          .environmentObject(UserSession())
      }
    }
    
    6. ReplyView.swift
    
    import SwiftUI
    
    struct ReplyView: View {
      var messageID: String
      var username: String
      var messageBody: String
      var createdAt: Date?
    
      let dateFormatter = DateFormatter()
    
      func dateFormatted(_ date: Date?) -> String {
        guard let date = date else { return "" }
        dateFormatter.dateFormat = "HH:mm EEEE MMMM d"
        return dateFormatter.string(from: date)
      }
    
      var body: some View {
        Group {
          HStack(alignment: .top) {
            UserAvatar(username: username)
              .frame(width: 25, height: 25, alignment: .center)
            VStack(alignment: .leading) {
              HStack(alignment: .lastTextBaseline) {
                Text(username)
                  .font(.caption)
                  .fontWeight(.bold)
                Text(dateFormatted(createdAt))
                  .font(.caption)
              }
              Text(messageBody)
                .font(.body)
            }
          }
        }
        .padding()
        .background(Color.backgroundColor)
      }
    }
    
    struct ReplyView_Previews: PreviewProvider {
      static var previews: some View {
        ReplyView(
          messageID: "0",
          username: "Charlie",
          messageBody: "Absolutely - do you want to give me a list?",
          createdAt: Date()
        )
      }
    }
    
    7. RepliesView.swift
    
    import SwiftUI
    
    struct RepliesView: View {
      var repliesList: [ReplyModel]
    
      var body: some View {
        List(repliesList) { reply in
          VStack(alignment: .leading) {
            ReplyView(
              messageID: reply.id,
              username: reply.authorName,
              messageBody: reply.body,
              createdAt: reply.createdAt
            )
            Divider()
          }
        }
        .listStyle(GroupedListStyle())
        .padding(.leading)
      }
    }
    
    struct RepliesView_Previews: PreviewProvider {
      static var previews: some View {
        let sampleReplies = [
          //      ReplyModel(id: "0", body: "Sure, what do you need?", authorName: "Charlie", messageId: "0", createdAt: Date()),
          //      ReplyModel(id: "1", body: "Yup! Give me a call?", authorName: "Andy", messageId: "0", createdAt: Date()),
          ReplyModel(id: "0", body: "Sure, what do you need?", authorName: "Charlie", messageId: "0", createdAt: Date()),
          ReplyModel(id: "1", body: "Yup! Give me a call?", authorName: "Andy", messageId: "0", createdAt: Date())
        ]
    
        return RepliesView(
          repliesList: sampleReplies
        )
      }
    }
    
    8. MessageDetailsHeader.swift
    
    import SwiftUI
    
    struct MessageDetailsHeader: View {
      var message: MessageModel?
    
      var body: some View {
        guard let message = message else {
          return AnyView(EmptyView())
        }
    
        return AnyView(Group {
          VStack(alignment: .leading) {
            MessageView(
              messageID: message.id,
              username: message.authorName,
              messageBody: message.body,
              createdAt: message.createdAt
            )
            Divider()
          }
        })
      }
    }
    
    struct MessageDetailsHeader_Previews: PreviewProvider {
      static var previews: some View {
        let sampleMessage = MessageModel(
          id: "0",
          body: "Help Needed. Can somebody buy me some groceries please",
          authorName: "Lizzie",
          messageThreadId: "0",
          createdAt: Date()
        )
    
        return MessageDetailsHeader(message: sampleMessage)
      }
    }
    
    9. MessageInputBar.swift
    
    import SwiftUI
    
    struct MessageInputBar: View {
      @EnvironmentObject var user: UserSession
      @State var message = CreateMessageInput(body: "", messageThreadId: "")
    
      var model: MessagesScreenViewModel
    
      var body: some View {
        HStack {
          TextField("Ask for help", text: $message.body)
            .padding(.trailing)
            .textFieldStyle(RoundedBorderTextFieldStyle())
          Button(
            action: {
              handleButtonTap()
            },
            label: {
              Text("Send")
            }
          )
            .disabled(message.body.count < 5)
        }
        .padding()
      }
    
      func handleButtonTap() {
        message.messageThreadId = model.threadID
        model.perform(action: .addMessage(message))
        message.body = ""
      }
    }
    
    struct MessageInputBar_Previews: PreviewProvider {
      static var previews: some View {
        MessageInputBar(
          model: MessagesScreenViewModel(userSession: UserSession(), threadID: "0")
        )
          .environmentObject(UserSession())
      }
    }
    
    10. MessagesScreen.swift
    
    import SwiftUI
    import Combine
    
    struct MessagesScreen: View {
      @ObservedObject private(set) var model: MessagesScreenViewModel
    
      var body: some View {
        Loadable(loadingState: model.messageListState) { messageList in
          ZStack {
            Color.backgroundColor
              .edgesIgnoringSafeArea(.all)
            VStack {
              MessagesView(messageList: messageList)
              MessageInputBar(model: model)
            }
            .navigationBarTitle(Text("Requests for Help"))
            .keyboardAdaptive()
          }
        }
        .onAppear {
          model.perform(action: .fetchMessages)
          model.perform(action: .subscribe)
        }
      }
    }
    
    struct MessagesScreen_Previews: PreviewProvider {
      static var previews: some View {
        let sampleData = [
          MessageModel(
            id: "0",
            body: "Help Needed. Can somebody buy me some groceries please?",
            authorName: "Lizzie",
            messageThreadId: "0",
            createdAt: Date()
          ),
          MessageModel(
            id: "1",
            body: "Dog walking request please",
            authorName: "Charlie",
            messageThreadId: "0",
            createdAt: Date()
          ),
          MessageModel(
            id: "2",
            body: "Anyone have any loo roll, I'm out!",
            authorName: "Andy",
            messageThreadId: "0",
            createdAt: Date()
          )
        ]
    
        return MessagesScreen(
          model: MessagesScreenViewModel(
            userSession: UserSession(),
            threadID: "0",
            messages: sampleData
        ))
          .environmentObject(ViewModelFactory(userSession: UserSession()))
      }
    }
    
    11. MessageView.swift
    
    import SwiftUI
    
    struct MessageView: View {
      var messageID: String
      var username: String
      var messageBody: String
      var createdAt: Date?
    
      let dateFormatter = DateFormatter()
    
      func dateFormatted(_ date: Date?) -> String {
        guard let date = date else { return "" }
        dateFormatter.dateFormat = "HH:mm EEEE MMMM d"
        return dateFormatter.string(from: date)
      }
    
      var body: some View {
        HStack(alignment: .top) {
          UserAvatar(username: username)
            .frame(width: 25, height: 25, alignment: .center)
          VStack(alignment: .leading) {
            HStack(alignment: .lastTextBaseline) {
              Text(username)
                .font(.caption)
                .fontWeight(.bold)
              Text(dateFormatted(createdAt))
                .font(.caption)
            }
            Text(messageBody)
              .font(.body)
          }
          Spacer()
        }
        .padding()
      }
    }
    
    struct MessageView_Previews: PreviewProvider {
      static var previews: some View {
        MessageView(
          messageID: "0",
          username: "Bob",
          messageBody: "Help Needed. Can somebody buy me some groceries please?",
          createdAt: Date()
        )
      }
    }
    
    12. MessagesView.swift
    
    import SwiftUI
    
    struct MessagesView: View {
      @EnvironmentObject var viewModelFactory: ViewModelFactory
    
      var messageList: [MessageModel]
    
      var body: some View {
        List(messageList) { message in
          VStack(alignment: .leading) {
            NavigationLink(
              destination: RepliesScreen(model: viewModelFactory.getOrCreateRepliesScreenViewModel(for: message.id))
            ) {
              MessageView(
                messageID: message.id,
                username: message.authorName,
                messageBody: message.body,
                createdAt: message.createdAt
              )
            }
            Divider()
          }
        }
      }
    }
    
    struct MessagesView_Previews: PreviewProvider {
      static var previews: some View {
        let sampleData = [
          MessageModel(
            id: "0",
            body: "Help Needed. Can somebody buy me some groceries please?",
            authorName: "Lizzie",
            messageThreadId: "0",
            createdAt: Date()
          ),
          MessageModel(
            id: "1",
            body: "Dog walking request please",
            authorName: "Charlie",
            messageThreadId: "0",
            createdAt: Date()
          ),
          MessageModel(
            id: "2",
            body: "Anyone have any loo roll, I'm out!",
            authorName: "Andy",
            messageThreadId: "0",
            createdAt: Date()
          )
        ]
    
        return MessagesView(messageList: sampleData)
          .environmentObject(UserSession())
          .environmentObject(ViewModelFactory(userSession: UserSession()))
      }
    }
    
    13. ActivityIndicator.swift
    
    import UIKit
    import SwiftUI
    
    struct ActivityIndicator: UIViewRepresentable {
      @Binding var isAnimating: Bool
      let style: UIActivityIndicatorView.Style
    
      func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
        UIActivityIndicatorView(style: style)
      }
    
      func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
        isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
      }
    }
    
    14. KeyboardAdaptive.swift
    
    import SwiftUI
    import Combine
    
    struct KeyboardAdaptive: ViewModifier {
      @State var bottomPadding: CGFloat = 0
    
      func body(content: Content) -> some View {
        GeometryReader { geometry in
          content
            .padding(.bottom, bottomPadding)
            .onReceive(Publishers.keyboardHeight) { keyboardInfo in
              bottomPadding = max(0, keyboardInfo.keyboardHeight - geometry.safeAreaInsets.bottom)
            }
            .animation(.easeInOut(duration: 0.25))
        }
      }
    }
    
    struct KeyboardInfo {
      let keyboardHeight: CGFloat
      let animationCurve: UIView.AnimationCurve
      let animationDuration: TimeInterval
    }
    
    extension View {
      func keyboardAdaptive() -> some View {
        ModifiedContent(content: self, modifier: KeyboardAdaptive())
      }
    }
    
    extension Publishers {
      static var keyboardHeight: AnyPublisher<KeyboardInfo, Never> {
        let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
          .map {
            KeyboardInfo(
              keyboardHeight: $0.keyboardHeight,
              animationCurve: $0.animationCurve,
              animationDuration: $0.animatinDuration
            )
          }
    
        let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
          .map {
            KeyboardInfo(
              keyboardHeight: 0,
              animationCurve: $0.animationCurve,
              animationDuration: $0.animatinDuration
            )
          }
    
        return MergeMany(willShow, willHide)
          .eraseToAnyPublisher()
      }
    }
    
    extension Notification {
      var keyboardHeight: CGFloat {
        (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
      }
    
      var animationCurve: UIView.AnimationCurve {
        userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UIView.AnimationCurve ?? .easeInOut
      }
    
      var animatinDuration: TimeInterval {
        userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? Double(0.16)
      }
    }
    
    // From https://stackoverflow.com/a/14135456/6870041
    extension UIResponder {
      static var currentFirstResponder: UIResponder? {
        privateCurrentFirstResponder = nil
        UIApplication.shared.sendAction(#selector(UIResponder.findFirstResponder(_:)), to: nil, from: nil, for: nil)
        return privateCurrentFirstResponder
      }
    
      static weak var privateCurrentFirstResponder: UIResponder?
    
      @objc func findFirstResponder(_ sender: Any) {
        UIResponder.privateCurrentFirstResponder = self
      }
    
      var globalFrame: CGRect? {
        guard let view = self as? UIView else { return nil }
        return view.superview?.convert(view.frame, to: nil)
      }
    }
    
    15. String+Postcode.swift
    
    import Foundation
    
    let postcodeRegexPattern =
    """
    ^((GIR\\s?0AA)|((([A-PR-UWYZ][A-HK-Y]?[0-9][0-9]?)\
    |(([A-PR-UWYZ][0-9][A-HJKSTUW])|([A-PR-UWYZ][A-HK-Y]\
    [0-9][ABEHMNPRV-Y])))\\s?[0-9][ABD-HJLNP-UW-Z]{2}))$
    """
    
    extension String {
      func isValidPostcode() -> Bool {
        do {
          let regex = try NSRegularExpression(pattern: postcodeRegexPattern)
          let range = NSRange(location: 0, length: utf16.count)
          let match = regex.firstMatch(in: self, options: [], range: range)
          let isValid = match != nil
          return isValid
        } catch {
          return false
        }
      }
    
      func postcodeArea() -> String? {
        if isValidPostcode() {
          return String(dropLast(3).trimmingCharacters(in: .whitespaces))
        } else {
          return nil
        }
      }
    }
    
    16. ErrorTypes.swift
    
    import Foundation
    
    enum IsolationNationError: Error {
      case appSyncClientNotInitialized
      case invalidPostcode
      case noRecordReturnedFromAPI
      case unexpectedGraphQLData
      case unexpctedAuthResponse
    }
    
    17. UserAvatar.swift
    
    import SwiftUI
    
    enum AvatarColors: CaseIterable {
      case red
      case orange
      case yellow
      case teal
    
      func color() -> Color {
        switch self {
        case .red:
          return Color(UIColor.systemRed)
        case .orange:
          return Color(UIColor.systemOrange)
        case .yellow:
          return Color(UIColor.systemYellow)
        case .teal:
          return Color(UIColor.systemTeal)
        }
      }
    }
    
    struct UserAvatar: View {
      let username: String
    
      func colorForUser() -> Color {
        guard let firstLetterInASCII = username.unicodeScalars.first?.value else {
          return AvatarColors.allCases.randomElement()?.color() ?? AvatarColors.red.color()
        }
    
        let index = Int(firstLetterInASCII) % AvatarColors.allCases.count
        return AvatarColors.allCases[index].color()
      }
    
      func letterForAvatar() -> String {
        if let char = username.first {
          return String(char)
        }
        return ""
      }
    
      var body: some View {
        GeometryReader { geometry in
          ZStack {
            Rectangle()
              .fill(colorForUser())
              .cornerRadius(geometry.size.width / 8)
            Text(letterForAvatar())
              .font(.system(size: geometry.size.width / 2))
          }
          .shadow(radius: geometry.size.width / 20.0)
        }
      }
    }
    
    struct UserAvatar_Previews: PreviewProvider {
      static var previews: some View {
        UserAvatar(username: "Bob")
      }
    }
    
    18. Color.swift
    
    import UIKit
    import SwiftUI
    
    extension UIColor {
      static var backgroundColor = UIColor.systemGroupedBackground
    }
    
    extension Color {
      static var backgroundColor = Color(UIColor.systemGroupedBackground)
      static var disabledForegroundColor = Color(red: 175 / 255, green: 175 / 255, blue: 175 / 255)
      static var disabledBackgroundColor = Color(red: 246 / 255, green: 246 / 255, blue: 246 / 255)
    }
    
    19. Loadable.swift
    
    import SwiftUI
    
    struct Loadable<T, Content: View>: View {
      let content: (T) -> Content
      let hideContentWhenLoading: Bool
      let loadingState: Loading<T>
    
      public init(loadingState: Loading<T>, @ViewBuilder content: @escaping (T) -> Content) {
        self.content = content
        self.hideContentWhenLoading = false
        self.loadingState = loadingState
      }
    
      public init(loadingState: Loading<T>, hideContentWhenLoading: Bool, @ViewBuilder content: @escaping (T) -> Content) {
        self.content = content
        self.hideContentWhenLoading = hideContentWhenLoading
        self.loadingState = loadingState
      }
    
      var body: some View {
        switch loadingState {
        case .loaded(let type), .updating(let type):
          return AnyView(content(type))
        case .loading(let type):
          return AnyView(
            ZStack {
              if !hideContentWhenLoading {
                content(type)
              }
              ActivityIndicator(isAnimating: .constant(true), style: .large)
                .opacity(0.9)
            }
          )
        case .errored:
          return AnyView(Text("Error loading view"))
        }
      }
    }
    
    struct Loadable_Previews: PreviewProvider {
      static var previews: some View {
        Loadable<String, Text>(loadingState: .loading("Hello World")) { string in
          Text(string)
        }
      }
    }
    
    20. Loading.swift
    
    import Foundation
    
    enum Loading<T> {
      case loading(T)
      case loaded(T)
      case updating(T)
      case errored(Error)
    }
    
    21. ViewModelFactory.swift
    
    import UIKit
    
    class ViewModelFactory: ObservableObject {
      let userSession: UserSession
    
      private var messagesScreenViewModels: [String: MessagesScreenViewModel] = [:]
      private var repliesScreenViewModels: [String: RepliesScreenViewModel] = [:]
    
      init(userSession: UserSession) {
        self.userSession = userSession
      }
    
      // Must be called on the main thread
      func getOrCreateMessagesScreenViewModel(for threadID: String) -> MessagesScreenViewModel {
        if let existingModel = messagesScreenViewModels[threadID] {
          return existingModel
        }
    
        let newModel = MessagesScreenViewModel(userSession: userSession, threadID: threadID)
        messagesScreenViewModels[threadID] = newModel
    
        return newModel
      }
    
      // Must be called on the main thread
      func getOrCreateRepliesScreenViewModel(for messageID: String) -> RepliesScreenViewModel {
        if let existingModel = repliesScreenViewModels[messageID] {
          return existingModel
        }
    
        let newModel = RepliesScreenViewModel(userSession: userSession, messageID: messageID)
        repliesScreenViewModels[messageID] = newModel
    
        return newModel
      }
    }
    
    22. PrimaryButton.swift
    
    import SwiftUI
    
    struct PrimaryButtonStyle: ButtonStyle {
      func makeBody(configuration: Self.Configuration) -> some View {
        PrimaryButton(configuration: configuration)
      }
    
      struct PrimaryButton: View {
        let configuration: ButtonStyle.Configuration
        @Environment(\.isEnabled) private var isEnabled: Bool
    
        var body: some View {
          configuration.label
            .padding()
            .frame(maxWidth: .infinity)
            .foregroundColor(isEnabled ? Color.white : Color.disabledForegroundColor)
            .background(isEnabled ? Color(UIColor.systemRed) : Color.disabledBackgroundColor)
        }
      }
    }
    
    struct PrimaryButton<Label>: View where Label: View {
      let action: () -> Void
      let label: () -> Label
    
      init(action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) {
        self.action = action
        self.label = label
      }
    
      var body: some View {
        Button(
          action: action,
          label: label
        )
          .buttonStyle(PrimaryButtonStyle())
      }
    }
    
    23. AppDelegate.swift
    
    import UIKit
    import Amplify
    import AmplifyPlugins
    
    @UIApplicationMain
    class AppDelegate: UIResponder, UIApplicationDelegate {
      let logger = Logger()
      let userSession = UserSession()
    
      func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        let authenticationService = AuthenticationService(userSession: userSession)
    
        do {
          let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels())
          try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
          try Amplify.add(plugin: dataStorePlugin)
          try Amplify.add(plugin: AWSCognitoAuthPlugin())
          try Amplify.add(plugin: AWSPinpointAnalyticsPlugin())
          try Amplify.configure()
    
          #if DEBUG
          Amplify.Logging.logLevel = .debug
          #else
          Amplify.Logging.logLevel = .error
          #endif
        } catch {
          print("Error initializing Amplify. \(error)")
        }
    
        // Handle Authentication
        authenticationService.checkAuthSession()
    
        // Listen to auth changes
        _ = Amplify.Hub.listen(to: .auth) { payload in
          switch payload.eventName {
          case HubPayload.EventName.Auth.sessionExpired:
            authenticationService.checkAuthSession()
    
          default:
            break
          }
        }
    
        // Theme setup
        UINavigationBar.appearance().backgroundColor = .backgroundColor
        UITableView.appearance().backgroundColor = .backgroundColor
        UITableView.appearance().separatorStyle = .none
        UITableViewCell.appearance().backgroundColor = .backgroundColor
        UITableViewCell.appearance().selectionStyle = .none
    
        return true
      }
    
      func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
      }
    }
    
    // MARK: AWSMobileClient - Authentication
    
    extension AppDelegate {
      func sendUserSignInEvent() {
        logger.logAnalyticsEvent(.signIn)
      }
    
      func sendUserSignOutEvent() {
        logger.logAnalyticsEvent(.signOut)
      }
    }
    
    24. SceneDelegate.swift
    
    import UIKit
    import SwiftUI
    
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
      var window: UIWindow?
    
      func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene, let app = UIApplication.shared.delegate as? AppDelegate {
          let window = UIWindow(windowScene: windowScene)
          window.rootViewController = UIHostingController(rootView: RootView(
            userSession: app.userSession,
            viewModelFactory: ViewModelFactory(userSession: app.userSession)
          ))
          self.window = window
          window.makeKeyAndVisible()
        }
      }
    }
    
    25. RootView.swift
    
    import SwiftUI
    
    struct RootView: View {
      @ObservedObject var userSession: UserSession
    
      var viewModelFactory: ViewModelFactory
    
      var body: some View {
        if !$userSession.loaded.wrappedValue {
          return AnyView(
            ZStack {
              Color.backgroundColor
                .edgesIgnoringSafeArea(.all)
              ActivityIndicator(isAnimating: .constant(true), style: .large)
            }
          )
        }
    
        guard let loggedInUser = $userSession.loggedInUser.wrappedValue else {
          return AnyView(
            SignUpOrSignInView(model: SignUpOrSignInViewModel(userSession: userSession))
          )
        }
    
        return AnyView(ZStack {
          Color.backgroundColor
            .edgesIgnoringSafeArea(.all)
          VStack {
            HomeScreen(
              model: HomeScreenViewModel(userID: loggedInUser.sub, username: loggedInUser.username)
            )
              .environmentObject(userSession)
              .environmentObject(viewModelFactory)
          }
        }
        .accentColor(Color(UIColor.systemPink)))
      }
    }
    
    struct RootView_Previews: PreviewProvider {
      static var previews: some View {
        let userSession = UserSession()
        return RootView(userSession: userSession, viewModelFactory: ViewModelFactory(userSession: userSession))
      }
    }
    
    26. Logger.swift
    
    import Foundation
    import Amplify
    
    enum AnalyticsEvent: String {
      case signIn = "userauth.sign_in"
      case signOut = "userauth.sign_out"
      case createUser
      case createThread
      case createMessage
      case createReply
    }
    
    class Logger {
      func logAnalyticsEvent(_ event: AnalyticsEvent, withProperties properties: AnalyticsProperties? = nil) {
        let event = BasicAnalyticsEvent(name: "CreateMessage", properties: properties)
        Amplify.Analytics.record(event: event)
      }
    
      func logError(_ items: Any...) {
        print(items)
      }
    }
    
    27. UserSession.swift
    
    import Combine
    import SwiftUI
    
    public final class UserSession: ObservableObject {
      @Published var loaded = false
      @Published var loggedInUser: User? {
        didSet {
          loaded = true
        }
      }
    
      init() {}
    
      init(loggedInUser: User?) {
        self.loggedInUser = loggedInUser
      }
    }
    
    28. AuthenticationService.swift
    
    import Combine
    import Amplify
    
    enum AuthenticationState {
      case startingSignUp
      case startingSignIn
      case awaitingConfirmation(String, String)
      case signedIn
      case errored(Error)
    }
    
    public final class AuthenticationService {
      let userSession: UserSession
      var logger = Logger()
      var cancellable: AnyCancellable?
    
      init(userSession: UserSession) {
        self.userSession = userSession
      }
    
      // MARK: Public API
    
      func signIn(as username: String, identifiedBy password: String) -> Future<AuthenticationState, Error> {
        return Future { promise in
          // 1
          _ = Amplify.Auth.signIn(username: username, password: password) { [self] result in
            switch result {
            // 2
            case .failure(let error):
              logger.logError(error.localizedDescription)
              promise(.failure(error))
            // 3
            case .success:
              guard let authUser = Amplify.Auth.getCurrentUser() else {
                let authError = IsolationNationError.unexpctedAuthResponse
                logger.logError(authError)
                signOut()
                promise(.failure(authError))
                return
              }
              // 4
              cancellable = fetchUserModel(id: authUser.userId)
                .sink(receiveCompletion: { completion in
                  switch completion {
                  case .failure(let error):
                    signOut()
                    promise(.failure(error))
                  case .finished: ()
                  }
                }, receiveValue: { user in
                  setUserSessionData(user)
                  promise(.success(.signedIn))
                })
            }
          }
        }
      }
    
      func signUp(as username: String, identifiedBy password: String, with email: String) -> Future<AuthenticationState, Error> {
        return Future { promise in
          // 1
          let userAttributes = [AuthUserAttribute(.email, value: email)]
          let options = AuthSignUpRequest.Options(userAttributes: userAttributes)
          // 2
          _ = Amplify.Auth.signUp(username: username, password: password, options: options) { [self] result in
            DispatchQueue.main.async {
              switch result {
              case .failure(let error):
                logger.logError(error.localizedDescription)
                promise(.failure(error))
              case .success(let amplifyResult):
                // 3
                if case .confirmUser = amplifyResult.nextStep {
                  promise(.success(.awaitingConfirmation(username, password)))
                } else {
                  let error = IsolationNationError.unexpctedAuthResponse
                  logger.logError(error.localizedDescription)
                  promise(.failure(error))
                }
              }
            }
          }
        }
      }
    
      func confirmSignUp(for username: String, with password: String, confirmedBy confirmationCode: String) -> Future<AuthenticationState, Error> {
        return Future { promise in
          _ = Amplify.Auth.confirmSignUp(for: username, confirmationCode: confirmationCode) { [self] result in
            switch result {
            case .failure(let error):
              logger.logError(error.localizedDescription)
              promise(.failure(error))
            case .success:
              _ = Amplify.Auth.signIn(username: username, password: password) { result in
                switch result {
                case .failure(let error):
                  logger.logError(error.localizedDescription)
                  promise(.failure(error))
                case .success:
                  guard let authUser = Amplify.Auth.getCurrentUser() else {
                    let authError = IsolationNationError.unexpctedAuthResponse
                    logger.logError(authError)
                    promise(.failure(IsolationNationError.unexpctedAuthResponse))
                    signOut()
                    return
                  }
    
                  let sub = authUser.userId
                  let user = User(
                    id: sub,
                    username: username,
                    sub: sub,
                    postcode: nil,
                    createdAt: Temporal.DateTime.now()
                  )
                  _ = Amplify.API.mutate(request: .create(user)) { [self] event in
                    switch event {
                    case .failure(let error):
                      signOut()
                      promise(.failure(error))
                    case .success(let result):
                      switch result {
                      case .failure(let error):
                        signOut()
                        promise(.failure(error))
                      case .success(let user):
                        setUserSessionData(user)
                        promise(.success(.signedIn))
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    
      func signOut() {
        setUserSessionData(nil)
        _ = Amplify.Auth.signOut { [self] result in
          switch result {
          case .failure(let error):
            logger.logError(error)
          default:
            break
          }
        }
      }
    
      func checkAuthSession() {
        // If signed in, we want to set the user data values
        // 1
        _ = Amplify.Auth.fetchAuthSession { [self] result in
          switch result {
          // 2
          case .failure(let error):
            logger.logError(error)
            signOut()
    
          // 3
          case .success(let session):
            if !session.isSignedIn {
              setUserSessionData(nil)
              return
            }
    
            // 4
            guard let authUser = Amplify.Auth.getCurrentUser() else {
              let authError = IsolationNationError.unexpctedAuthResponse
              logger.logError(authError)
              signOut()
              return
            }
    
            let sub = authUser.userId
            cancellable = fetchUserModel(id: sub)
              .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                  logger.logError(error)
                  signOut()
                case .finished:
                  break
                }
              }, receiveValue: { user in
                setUserSessionData(user)
              })
          }
        }
      }
    
      // MARK: Private
    
      private func setUserSessionData(_ user: User?, authUser: AuthUser? = nil) {
        DispatchQueue.main.async { [self] in
          if let user = user {
            userSession.loggedInUser = user
          } else {
            userSession.loggedInUser = nil
          }
          if let authUser = authUser {
            identifyUserToAnalyticsService(authUser)
          }
        }
      }
    
      private func fetchUserModel(id: String) -> Future<User, Error> {
        return Future { promise in
          _ = Amplify.API.query(request: .get(User.self, byId: id)) { [self] event in
            switch event {
            case .failure(let error):
              logger.logError(error.localizedDescription)
              promise(.failure(error))
              return
            case .success(let result):
              switch result {
              case .failure(let resultError):
                logger.logError(resultError.localizedDescription)
                promise(.failure(resultError))
                return
              case .success(let user):
                guard let user = user else {
                  let error = IsolationNationError.unexpectedGraphQLData
                  logger.logError(error.localizedDescription)
                  promise(.failure(error))
                  return
                }
                promise(.success(user))
              }
            }
          }
        }
      }
    }
    
    // MARK: AWS Pinpoint
    
    extension AuthenticationService {
      // 1
      func identifyUserToAnalyticsService(_ user: AuthUser) {
        // 2
        let userProfile = AnalyticsUserProfile(
          name: user.username,
          email: nil,
          plan: nil,
          location: nil,
          properties: nil
        )
        // 3
        Amplify.Analytics.identifyUser(user.userId, withProfile: userProfile)
      }
    }
    
    29.  HomeScreenViewModel.swift
    
    import SwiftUI
    import Combine
    import Amplify
    
    struct UserModel {
      let id: String
      let username: String
      let sub: String
      let postcode: String?
    }
    
    enum HomeScreenViewModelAction {
      case fetchUserPostcode
      case addPostCode(String)
    }
    
    final class HomeScreenViewModel: ObservableObject {
      let userID: String
      let username: String
      let logger: Logger?
    
      // MARK: - Publishers
      @Published var userPostcodeState: Loading<String?>
    
      var cancellable: AnyCancellable?
    
      init(userID: String, username: String) {
        self.userID = userID
        self.username = username
        userPostcodeState = .loading(nil)
        logger = Logger()
    
        fetchUser()
      }
    
      init(userID: String, user: UserModel) {
        self.userID = userID
        username = user.username
        userPostcodeState = .loaded(user.postcode)
        logger = nil
      }
    
      // MARK: Actions
    
      func perform(action: HomeScreenViewModelAction) {
        switch action {
        case .fetchUserPostcode:
          fetchUser()
        case .addPostCode(let postcode):
          addPostCode(postcode)
        }
      }
    
      // MARK: Action handlers
    
      private func fetchUser() {
        userPostcodeState = .loading(nil)
        _ = Amplify.API.query(request: .get(User.self, byId: userID)) { [self] event in
          DispatchQueue.main.async {
            switch event {
            case .failure(let error):
              logger?.logError(error.localizedDescription)
              userPostcodeState = .errored(error)
              return
            case .success(let result):
              switch result {
              case .failure(let resultError):
                logger?.logError(resultError.localizedDescription)
                userPostcodeState = .errored(resultError)
                return
              case .success(let user):
                guard
                  let user = user,
                  let postcode = user.postcode
                else {
                  userPostcodeState = .loaded(nil)
                  return
                }
                userPostcodeState = .loaded(postcode)
              }
            }
          }
        }
      }
    
      private func addPostCode(_ postcode: String) {
        _ = Amplify.API.query(request: .get(User.self, byId: userID)) { [self] event in
          DispatchQueue.main.async {
            switch event {
            case .failure(let error):
              logger?.logError("Error occurred: \(error.localizedDescription )")
              userPostcodeState = .errored(error)
            case .success(let result):
              switch result {
              case .failure(let resultError):
                logger?.logError("Error occurred: \(resultError.localizedDescription )")
                userPostcodeState = .errored(resultError)
              case .success(let user):
                guard var user = user else {
                  let error = IsolationNationError.noRecordReturnedFromAPI
                  userPostcodeState = .errored(error)
                  return
                }
    
                user.postcode = postcode
                Amplify.DataStore.save(user) { result in
                  DispatchQueue.main.async {
                    switch result {
                    case .failure(let error):
                      logger?.logError("Error occurred: \(error.localizedDescription )")
                      userPostcodeState = .errored(error)
                    case .success:
                      // Now we have a user, check to see if there is a Thread already created
                      // for their postcode. If not, create it.
                      guard let location = postcode.postcodeArea() else {
                        logger?.logError(
                          "Could not find location within postcode '\(String(describing: postcode))'. Aborting."
                        )
                        userPostcodeState = .errored(
                          IsolationNationError.invalidPostcode
                        )
                        return
                      }
                      addUser(user, to: location)
                    }
                  }
                }
              }
            }
          }
        }
      }
    
      // MARK: - Private functions
    
      // 1
      private func addUser(_ user: User, to thread: Thread) -> Future<String, Error> {
        return Future { promise in
          // 2
          let userThread = UserThread(user: user, thread: thread, createdAt: Temporal.DateTime.now())
          // 3
          Amplify.DataStore.save(userThread) { result in
            // 4
            switch result {
            case .failure(let error):
              promise(.failure(error))
            case .success(let userThread):
              promise(.success(userThread.id))
            }
          }
        }
      }
    
      private func createThread(_ location: String) -> Future<Thread, Error> {
        return Future { promise in
          let thread = Thread(name: location, location: location, createdAt: Temporal.DateTime.now())
          Amplify.DataStore.save(thread) { result in
            switch result {
            case .failure(let error):
              promise(.failure(error))
            case .success(let thread):
              promise(.success(thread))
            }
          }
        }
      }
    
      private func fetchOrCreateThreadWithLocation(location: String) -> Future<Thread, Error> {
        return Future { promise in
          let threadHasLocation = Thread.keys.location == location
          _ = Amplify.API.query(request: .list(Thread.self, where: threadHasLocation)) { [self] event in
            switch event {
            case .failure(let error):
              logger?.logError("Error occurred: \(error.localizedDescription )")
              promise(.failure(error))
            case .success(let result):
              switch result {
              case .failure(let resultError):
                logger?.logError("Error occurred: \(resultError.localizedDescription )")
                promise(.failure(resultError))
              case .success(let threads):
                guard let thread = threads.first else {
                  // Need to create the Thread
                  _ = createThread(location).sink(
                    receiveCompletion: { completion in
                      switch completion {
                      case .failure(let error): promise(.failure(error))
                      case .finished:
                        break
                      }
                    },
                    receiveValue: { thread in
                      promise(.success(thread))
                    }
                  )
                  return
                }
                promise(.success(thread))
              }
            }
          }
        }
      }
    
      private func addUser(_ user: User, to location: String) {
        cancellable = fetchOrCreateThreadWithLocation(location: location)
          .flatMap { thread in
            return self.addUser(user, to: thread)
          }
          .receive(on: DispatchQueue.main)
          .sink(
            receiveCompletion: { completion in
              switch completion {
              case .failure(let error):
                self.userPostcodeState = .errored(error)
              case .finished:
                break
              }
            },
            receiveValue: { _ in
              self.userPostcodeState = .loaded(user.postcode)
            }
        )
      }
    }
    
    30. ThreadsScreenViewModel.swift
    
    import Combine
    import SwiftUI
    import Amplify
    
    struct ThreadModel: Identifiable {
      let id: String
      let name: String
    }
    
    enum ThreadsScreenViewModelAction {
      case fetchThreads
    }
    
    final class ThreadsScreenViewModel: ObservableObject {
      let userSession: UserSession
      let logger: Logger?
    
      var threadList: [ThreadModel] = []
    
      // MARK: Publishers
      @Published var threadListState: Loading<[ThreadModel]>
    
      init(userSession: UserSession) {
        self.userSession = userSession
        threadListState = .loading([])
        logger = Logger()
    
        fetchThreads()
      }
    
      init(userSession: UserSession, threads: [ThreadModel]) {
        logger = nil
        self.userSession = userSession
        threadListState = .loaded(threads)
      }
    
      // MARK: Actions
    
      func perform(action: ThreadsScreenViewModelAction) {
        switch action {
        case .fetchThreads:
          fetchThreads()
        }
      }
    
      // MARK: Action handlers
    
      private func fetchThreads() {
        guard let loggedInUser = userSession.loggedInUser else {
          return
        }
        let userID = loggedInUser.id
    
        Amplify.DataStore.query(User.self, byId: userID) { [self] result in
          switch result {
          case .failure(let error):
            logger?.logError("Error occurred: \(error.localizedDescription )")
            threadListState = .errored(error)
            return
          case .success(let user):
            guard let user = user else {
              let error = IsolationNationError.unexpectedGraphQLData
              logger?.logError("Error fetching user \(userID): \(error)")
              threadListState = .errored(error)
              return
            }
    
            guard let userThreads = user.threads else {
              let error = IsolationNationError.unexpectedGraphQLData
              logger?.logError("Error fetching threads for user \(userID): \(error)")
              threadListState = .errored(error)
              return
            }
    
            threadList = userThreads.map { $0.thread.asModel() }
            threadListState = .loaded(threadList)
          }
        }
      }
    }
    
    // MARK: AWS Model to Model conversions
    
    extension Thread {
      func asModel() -> ThreadModel {
        ThreadModel(id: id, name: name)
      }
    }
    
    31. RepliesScreenViewModel.swift
    
    import Combine
    import SwiftUI
    import Amplify
    
    struct ReplyModel: Identifiable {
      let id: String
      let body: String
      let authorName: String
      let messageId: String?
      let createdAt: Date?
    }
    
    struct CreateReplyInput {
      var body: String
      var replyMessageId: String
    }
    
    enum RepliesScreenViewModelAction {
      case fetchReplies
      case subscribe
      case addReply(CreateReplyInput)
    }
    
    final class RepliesScreenViewModel: ObservableObject {
      let userSession: UserSession
      let messageID: String
      let logger: Logger?
    
      var replyList: [ReplyModel]
    
      // MARK: - Publishers
    
      @Published var message: MessageModel?
      @Published var replyListState: Loading<[ReplyModel]>
    
      init(userSession: UserSession, messageID: String) {
        self.userSession = userSession
        self.messageID = messageID
        replyList = []
        replyListState = .loading([])
        logger = Logger()
      }
    
      init(userSession: UserSession, messageID: String, message: MessageModel, replies: [ReplyModel]) {
        logger = nil
        self.userSession = userSession
        self.messageID = messageID
        replyList = replies
        replyListState = .loaded(replies)
      }
    
      // MARK: Actions
    
      func perform(action: RepliesScreenViewModelAction) {
        switch action {
        case .fetchReplies:
          fetchReplies()
        case .subscribe:
          subscribe()
        case .addReply(let input):
          addReply(input: input)
        }
      }
    
      // MARK: Action handlers
    
      private func fetchReplies() {
        Amplify.DataStore.query(Message.self, byId: messageID) { [self] messageResult in
          switch messageResult {
          case .failure(let error):
            logger?.logError("Error fetching replies for message \(messageID): \(error)")
            replyListState = .errored(error)
            return
    
          case .success(let message):
            self.message = message?.asModel()
            replyList = message?.replies?.sorted { $0.createdAt < $1.createdAt }.map({ $0.asModel() }) ?? []
            replyListState = .loaded(replyList)
          }
        }
      }
    
      private func addReply(input: CreateReplyInput) {
        guard let author = userSession.loggedInUser else {
          return
        }
    
        Amplify.DataStore.query(Message.self, byId: messageID) { [self] messageResult in
          switch messageResult {
          case .failure(let error):
            logger?.logError("Error fetching message \(messageID): \(error)")
            replyListState = .errored(error)
            return
    
          case .success(let message):
            var newReply = Reply(author: author, body: input.body, createdAt: Temporal.DateTime.now())
            newReply.message = message
            Amplify.DataStore.save(newReply) { saveResult in
              switch saveResult {
              case .failure(let error):
                logger?.logError("Error saving reply: \(error)")
                replyListState = .errored(error)
              case .success:
                return
              }
            }
          }
        }
    
        logger?.logAnalyticsEvent(.createReply, withProperties:
          [
            "MessageID": input.replyMessageId,
            "AuthorID": author.id
          ]
        )
      }
    
      private func subscriptionCompletionHandler(completion: Subscribers.Completion<DataStoreError>) {
        if case .failure(let error) = completion {
          logger?.logError("Error fetching replies for message \(messageID): \(error)")
          replyListState = .errored(error)
        }
      }
    
      var fetchReplySubscription: AnyCancellable?
      private func subscribe() {
        fetchReplySubscription = Amplify.DataStore.publisher(for: Reply.self)
          .receive(on: DispatchQueue.main)
          .sink(receiveCompletion: subscriptionCompletionHandler) { [self] changes in
            do {
              let reply = try changes.decodeModel(as: Reply.self)
    
              guard
                let replyMessageID = reply.message?.id,
                replyMessageID == messageID
              else {
                return
              }
    
              replyListState = .updating(replyList)
              let isNewReply = replyList.filter { $0.id == reply.id }.isEmpty
              if isNewReply {
                replyList.append(reply.asModel())
              }
              replyListState = .loaded(replyList)
            } catch {
              logger?.logError("\(error.localizedDescription)")
              replyListState = .errored(error)
            }
          }
      }
    }
    
    // MARK: AWS Model to Model conversions
    
    extension Reply {
      func asModel() -> ReplyModel {
        return ReplyModel(
          id: id,
          body: body,
          authorName: author.username,
          messageId: message?.id,
          createdAt: createdAt.foundationDate
        )
      }
    }
    
    32. MessagesScreenViewModel.swift
    
    import Combine
    import SwiftUI
    import Amplify
    
    struct MessageModel: Identifiable, Equatable {
      let id: String
      let body: String
      let authorName: String
      let messageThreadId: String?
      let createdAt: Date?
    }
    
    struct CreateMessageInput {
      var body: String
      var messageThreadId: String
    }
    
    enum MessagesScreenViewModelAction {
      case fetchMessages
      case subscribe
      case addMessage(CreateMessageInput)
    }
    
    final class MessagesScreenViewModel: ObservableObject {
      let userSession: UserSession
      let threadID: String
      let logger: Logger?
    
      var messageList: [MessageModel]
    
      // MARK: - Publishers
      @Published var messageListState: Loading<[MessageModel]>
    
      init(userSession: UserSession, threadID: String) {
        self.userSession = userSession
        self.threadID = threadID
        messageList = []
        messageListState = .loading([])
        logger = Logger()
      }
    
      init(userSession: UserSession, threadID: String, messages: [MessageModel]) {
        logger = nil
        self.userSession = userSession
        self.threadID = threadID
        messageList = messages
        messageListState = .loaded(messages)
      }
    
      // MARK: Actions
    
      func perform(action: MessagesScreenViewModelAction) {
        switch action {
        case .fetchMessages:
          fetchMessages()
        case .subscribe:
          subscribe()
        case .addMessage(let input):
          addMessage(input: input)
        }
      }
    
      // MARK: Action handlers
    
      private func fetchMessages() {
        Amplify.DataStore.query(Thread.self, byId: threadID) { [self] threadResult in
          switch threadResult {
          case .failure(let error):
            logger?.logError("Error fetching messages for thread \(threadID): \(error)")
            messageListState = .errored(error)
            return
    
          case .success(let thread):
            messageList = thread?.messages?.sorted { $0.createdAt < $1.createdAt }.map({ $0.asModel() }) ?? []
            messageListState = .loaded(messageList)
          }
        }
      }
    
      private func addMessage(input: CreateMessageInput) {
        guard let author = userSession.loggedInUser else {
          return
        }
    
        Amplify.DataStore.query(Thread.self, byId: threadID) { [self] threadResult in
          switch threadResult {
          case .failure(let error):
            logger?.logError("Error fetching thread \(threadID): \(error)")
            messageListState = .errored(error)
            return
    
          case .success(let thread):
            var newMessage = Message(author: author, body: input.body, createdAt: Temporal.DateTime.now())
            newMessage.thread = thread
            Amplify.DataStore.save(newMessage) { saveResult in
              switch saveResult {
              case .failure(let error):
                logger?.logError("Error saving message: \(error)")
                messageListState = .errored(error)
              case .success:
                return
              }
            }
          }
        }
    
        logger?.logAnalyticsEvent(.createMessage, withProperties:
          [
            "ThreadID": threadID,
            "AuthorID": author.id
          ]
        )
      }
    
      private func subscriptionCompletionHandler(completion: Subscribers.Completion<DataStoreError>) {
        if case .failure(let error) = completion {
          logger?.logError("Error fetching messages for thread \(threadID): \(error)")
          messageListState = .errored(error)
        }
      }
    
      var fetchMessageSubscription: AnyCancellable?
    
      private func subscribe() {
        fetchMessageSubscription = Amplify.DataStore.publisher(for: Message.self)
          .receive(on: DispatchQueue.main)
          .sink(receiveCompletion: subscriptionCompletionHandler) { [self] changes in
            do {
              let message = try changes.decodeModel(as: Message.self)
    
              guard
                let messageThreadID = message.thread?.id,
                messageThreadID == threadID
              else {
                return
              }
    
              messageListState = .updating(messageList)
              let isNewMessage = messageList.filter { $0.id == message.id }.isEmpty
              if isNewMessage {
                messageList.append(message.asModel())
              }
              messageListState = .loaded(messageList)
            } catch {
              logger?.logError("\(error.localizedDescription)")
              messageListState = .errored(error)
            }
          }
      }
    }
    
    // MARK: AWS Model to Model conversions
    
    extension Message {
      func asModel() -> MessageModel {
        MessageModel(
          id: id,
          body: body,
          authorName: author.username,
          messageThreadId: thread?.id,
          createdAt: createdAt.foundationDate
        )
      }
    }
    

    后记

    本篇主要讲述了Data Store,感兴趣的给个赞或者关注~~~

    相关文章

      网友评论

          本文标题:使用AWS构建后端(四) —— Data Store(二)

          本文链接:https://www.haomeiwen.com/subject/tlifiktx.html