使用AWS构建后端(二) —— Authentication & API(二)

版本记录

版本号 时间
V1.0 2020.11.13 星期五

前言

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

源码

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
  case notImplemented
}
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 {
  var logger = Logger()
  let userSession = UserSession()

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
    // Setup user
    let authenticationService = AuthenticationService(userSession: userSession)
    do {
      try Amplify.add(plugin: AWSCognitoAuthPlugin())
      try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
      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()

    // Theme setup
    UINavigationBar.appearance().backgroundColor = .backgroundColor
    UITableView.appearance().backgroundColor = .backgroundColor
    UITableView.appearance().separatorStyle = .none
    UITableViewCell.appearance().backgroundColor = .backgroundColor
    UITableViewCell.appearance().selectionStyle = .none

    // Listen to auth changes
    _ = Amplify.Hub.listen(to: .auth) { payload in
      switch payload.eventName {
      case HubPayload.EventName.Auth.sessionExpired:
        authenticationService.checkAuthSession()

      default:
        break
      }
    }

    return true
  }

  func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
  }
}
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

enum AnalyticsEvent: String {
  case signIn = "userauth.sign_in"
  case signOut = "userauth.sign_out"
  case createUser
  case createThread
  case createMessage
  case createReply
}

class Logger {
  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 UIKit
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:
                break
              }
            }, 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
      // 1
      _ = 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:
          // 2
          _ = Amplify.Auth.signIn(username: username, password: password) { result in
            switch result {
            case .failure(let error):
              logger.logError(error.localizedDescription)
              promise(.failure(error))
            case .success:
              // 3
              // 1
              guard let authUser = Amplify.Auth.getCurrentUser() else {
                let authError = IsolationNationError.unexpctedAuthResponse
                logger.logError(authError)
                promise(.failure(IsolationNationError.unexpctedAuthResponse))
                signOut()
                return
              }
              // 2
              let sub = authUser.userId
              let user = User(
                id: sub,
                username: username,
                sub: sub,
                postcode: nil,
                createdAt: Temporal.DateTime.now()
              )
              // 3
              _ = Amplify.API.mutate(request: .create(user)) { event in
                switch event {
                // 4
                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):
                    // 5
                    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() {
    // 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: ()
            }
          }, receiveValue: { user in
            setUserSessionData(user)
          })
      }
    }
  }

  // MARK: Private

  private func setUserSessionData(_ user: User?) {
    DispatchQueue.main.async {
      if let user = user {
        self.userSession.loggedInUser = user
      } else {
        self.userSession.loggedInUser = nil
      }
    }
  }

  private func fetchUserModel(id: String) -> Future<User, Error> {
    // 1
    return Future { promise in
      // 2
      _ = Amplify.API.query(request: .get(User.self, byId: id)) { [self] event in
        // 3
        switch event {
        case .failure(let error):
          logger.logError(error.localizedDescription)
          promise(.failure(error))
          return
        case .success(let result):
          // 4
          switch result {
          case .failure(let resultError):
            logger.logError(resultError.localizedDescription)
            promise(.failure(resultError))
            return
          case .success(let user):
            // 5
            guard let user = user else {
              let error = IsolationNationError.unexpectedGraphQLData
              logger.logError(error.localizedDescription)
              promise(.failure(error))
              return
            }
            promise(.success(user))
          }
        }
      }
    }
  }
}
29. HomeScreenViewModel.swift
import SwiftUI
import Combine

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 = .loaded("SW1A 1AA")
  }

  private func addPostCode(_ postcode: String) {
    // To implement
  }
}
30. ThreadsScreenViewModel.swift
import SwiftUI
import Combine

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
    self.threadListState = .loading([])
    logger = Logger()

    fetchThreads()
  }

  init(userSession: UserSession, threads: [ThreadModel]) {
    logger = nil
    self.userSession = userSession
    self.threadListState = .loaded(threads)
  }

  // MARK: Actions

  func perform(action: ThreadsScreenViewModelAction) {
    switch action {
    case .fetchThreads:
      fetchThreads()
    }
  }

  // MARK: Action handlers

  private func fetchThreads() {
    threadList = [ThreadModel(id: "0", name: "SW1A")]
    threadListState = .loaded(threadList)
  }
}
31. RepliesScreenViewModel.swift
import SwiftUI
import Combine

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() {
    message = MessageModel(
      id: "0",
      body: "Help Needed. Can somebody buy me some groceries please",
      authorName: "Lizzie",
      messageThreadId: "0",
      createdAt: Date()
    )
    replyList = [
      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())
    ]
    replyListState = .loaded(replyList)
  }

  private func addReply(input: CreateReplyInput) {
    // To implement
  }

  private func subscribe() {
    // To implement
  }
}
32. MessagesScreenViewModel.swift
import SwiftUI
import Combine

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() {
    messageList = [
      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()
      )
    ]
    messageListState = .loaded(messageList)
  }

  private func addMessage(input: CreateMessageInput) {
    // To implement
  }

  private func subscribe() {
    // To implement
  }
}

后记

本篇主要讲述了Xcode Server的安装和配置,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,907评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,546评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,705评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,624评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,940评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,371评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,672评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,396评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,069评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,350评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,876评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,243评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,847评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,004评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,755评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,378评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,266评论 2 259

推荐阅读更多精彩内容