版本记录
版本号 | 时间 |
---|---|
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
,感兴趣的给个赞或者关注~~~
网友评论