()
16 |
17 | var hasAvailableTicket: Bool {
18 | let currentDate = Date()
19 | return currentDate <= DateFormatter.calendarFormatter.date(from: ticketing?.currentTicket?.date ?? "") ?? Date()
20 | }
21 |
22 | var isTicketingLinkDisabled: Bool {
23 | ticketing?.currentTicket?.ticketingURL == nil && !hasAvailableTicket
24 | }
25 |
26 | func getTicketingData() {
27 | let url = URL(string: GitHubStorageURL.ticketingData)!
28 | URLSession.shared.dataTaskPublisher(for: url)
29 | .map(\.data)
30 | .decode(type: Ticketing.self, decoder: JSONDecoder())
31 | .receive(on: RunLoop.main)
32 | .sink { _ in
33 |
34 | } receiveValue: { [weak self] event in
35 | self?.ticketing = event
36 | }
37 | .store(in: &cancellable)
38 | }
39 |
40 | func didTappedTicketingButton() {
41 | isActivatedWebViewNavigationLink = true
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AsyncSwift
2 | ## AsyncSwift란?
3 | 
4 |
5 | ---
6 | ## AsyncSwift 앱 소개
7 | ### 1. 행사 안내
8 | ```
9 | 컨퍼런스가 앞으로 계획되어있다면 앞으로 열릴 컨퍼런스에 대한 발표 소개 및 연사자를 소개해줍니다.
10 | 컨퍼런스 계획이 아직 잡혀있지 않다면 가장 최신의 정보를 제공하게 됩니다.
11 | ```
12 |
13 |
14 |
15 |
16 | ### 2. 티켓팅 안내
17 | ```
18 | 컨퍼런스가 계획되어져 있다면 컨퍼런스 포스터와 함께 컨퍼런스 티켓(유/무료) 구매페이지로 안내되어집니다.
19 | 컨퍼런스 계획이 잡혀 있지 않다면 아래와 같이 비어있는 화면이 보여지게 됩니다.
20 | ```
21 |
22 |
23 |
24 |
25 | ### 3. 디지털 Stamp
26 | ```
27 | 컨퍼런스에서 얻을 수 있는 QR코드(DeepLink)를 찍으면 디지털 Stamp를 얻을 수 있습니다.
28 | 디지털 Stamp는 아래와 같이 애니메이션이 작동합니다.
29 | ```
30 |
31 |
32 |
33 |
34 |
35 | ### 4. Profile공유
36 | ```
37 | AsyncSwift에서 주최한 행사의 After Party에서 즐기실 수 있는 프로필 교환 및 작성을 위한 기능입니다.
38 | 서로의 QR코드 스캔을 통해서 프로필을 교환할 수 있습니다.
39 | ```
40 |
41 |
42 |
43 |
44 | ---
45 | ## 사용 기술
46 | ```Swift
47 | DeepLink
48 | Firebase
49 | KeyChain
50 | ```
51 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/ProfileView/ProfileFriendDetailViewObserved.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileFriendDetailViewObserved.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/11/04.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum PreviousView {
11 | case ProfileView
12 | case ListView
13 | }
14 |
15 | @MainActor
16 | final class ProfileFriendDetailViewObserved: ObservableObject {
17 | @Binding var inActive: Bool
18 | @Published var isShowingLinkedInSheet = false
19 | @Published var isShowingProfileSheet = false
20 | @Published var isShowingConfirmAlert = false
21 | let previous: PreviousView
22 | let friend: User
23 | var user: User
24 | var profileURL: URL? {
25 | URL(string: friend.profileURL)
26 | }
27 | var linkedInURL: URL? {
28 | URL(string: friend.linkedInURL)
29 | }
30 | var hasProfileURL: Bool {
31 | get {
32 | !friend.profileURL.isEmpty
33 | }
34 | }
35 | var hasLinkedInURL: Bool {
36 | get {
37 | !friend.linkedInURL.isEmpty
38 | }
39 | }
40 |
41 | init(inActive: Binding, user: User, friend: User, previous: PreviousView) {
42 | self._inActive = inActive
43 | self.friend = friend
44 | self.user = user
45 | self.previous = previous
46 | }
47 |
48 | func didTapDoneButton() {
49 | inActive = false
50 | }
51 |
52 | func didTapDeleteButton() {
53 | isShowingConfirmAlert = true
54 | }
55 |
56 | func didConfirmDelete() {
57 | Task {
58 | await removeFriendFromList()
59 | inActive = false
60 | }
61 | }
62 | }
63 |
64 | private extension ProfileFriendDetailViewObserved {
65 | func removeFriendFromList() async {
66 | let removedList = user.friends.filter { $0 != friend.id }
67 | user.friends = removedList
68 | FirebaseManager.shared.editUser(user: user)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/AsyncSwiftWidget/AsyncSwiftWidget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AsyncSwiftWidget.swift
3 | // AsyncSwiftWidget
4 | //
5 | // Created by 김인섭 on 2023/10/01.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | struct Provider: TimelineProvider {
12 | func placeholder(in context: Context) -> SimpleEntry {
13 | SimpleEntry(date: Date(), imageData: getRemoteImage())
14 | }
15 |
16 | func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
17 | let entry = SimpleEntry(date: Date(), imageData: getRemoteImage())
18 | completion(entry)
19 | }
20 |
21 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
22 |
23 | let entry = SimpleEntry(date: Date(), imageData: getRemoteImage())
24 | let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())
25 | let timeline = Timeline(entries: [entry], policy: .after(nextUpdate!))
26 | completion(timeline)
27 | }
28 |
29 | func getRemoteImage() -> Data? {
30 | try? Data(contentsOf: URL(string: GitHubStorageURL.widgetLargeImage)!)
31 | }
32 | }
33 |
34 | struct SimpleEntry: TimelineEntry {
35 | var date: Date
36 | let imageData: Data?
37 | }
38 |
39 | struct AsyncSwiftWidget: Widget {
40 | let kind: String = "AsyncSwiftWidget"
41 |
42 | var body: some WidgetConfiguration {
43 | StaticConfiguration(kind: kind, provider: Provider()) { entry in
44 | AsyncSwiftWidgetEntryView(entry: entry)
45 | }
46 | .configurationDisplayName("AsyncSwift")
47 | .description("행사 정보를 확인하세요.")
48 | .supportedFamilies(
49 | [.systemLarge]
50 | )
51 | }
52 | }
53 |
54 | struct AsyncSwiftWidget_Previews: PreviewProvider {
55 | static var previews: some View {
56 | AsyncSwiftWidgetEntryView(
57 | entry: SimpleEntry(
58 | date: Date(),
59 | imageData: try? Data(contentsOf: URL(string: GitHubStorageURL.widgetLargeImage)!)
60 | )
61 | )
62 | .previewContext(WidgetPreviewContext(family: .systemSmall))
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/EventView+Observed.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EventView+Observed.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/09/08.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 |
11 | final class EventViewObserved: ObservableObject {
12 |
13 | @Published var event = Event()
14 | @Published var eventStatus: EventStatus = .upcoming
15 | @Published var isLoading = true
16 | let onLoadingCells = Array(repeating: [0], count: 6)
17 | var cancellable = Set()
18 |
19 | func getEventData() {
20 | let url = URL(string: GitHubStorageURL.eventData)!
21 | URLSession.shared.dataTaskPublisher(for: url)
22 | .map(\.data)
23 | .decode(type: Event.self, decoder: JSONDecoder())
24 | .receive(on: RunLoop.main)
25 | .sink { _ in
26 |
27 | } receiveValue: { [weak self] event in
28 | self?.event = event
29 | self?.calculateEventStatus()
30 | self?.isLoading = false
31 | }
32 | .store(in: &cancellable)
33 | }
34 |
35 | func calculateEventStatus() {
36 | let formatter = DateFormatter.calendarFormatter
37 | guard
38 | let start = formatter.date(from: event.startDate),
39 | let end = formatter.date(from: event.endDate)
40 | else { return }
41 | let currentDate = Date()
42 |
43 | if currentDate < start {
44 | self.eventStatus = .upcoming
45 | } else if start <= currentDate && currentDate < end {
46 | self.eventStatus = .onProgress
47 | } else if currentDate > end {
48 | self.eventStatus = .done
49 | }
50 | }
51 | }
52 |
53 |
54 | extension EventViewObserved {
55 | enum EventStatus: String {
56 | case upcoming = "예정된 행사"
57 | case onProgress = "진행중인 행사"
58 | case done = "지나간 행사"
59 |
60 | var statusColor: Color {
61 | switch self {
62 | case .upcoming: return Color.accentColor
63 | case .onProgress: return Color.asyncBlue
64 | case .done: return Color.black
65 | }
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/EventDetailView+Observed.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EventDetailView+Observed.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/09/09.
6 | //
7 |
8 | import EventKit
9 | import SwiftUI
10 |
11 | extension EventDetailView {
12 |
13 | final class Observed: ObservableObject {
14 |
15 | init(event: Event) {
16 | self.event = event
17 | }
18 |
19 | let event: Event
20 | @Published var isShowingSheet = false
21 | @Published var isShowingAddEventConfirmationAlert = false
22 | @Published var isShowingAddEventSuccessAlert = false
23 | @Published var isShowingAddEventFailureAlert = false
24 |
25 | func additionConfirmed() {
26 | addEventOnCalendar { [weak self] isSuccess in
27 | DispatchQueue.main.async {
28 | switch isSuccess {
29 | case true:
30 | self?.isShowingAddEventSuccessAlert = true
31 | case false:
32 | self?.isShowingAddEventFailureAlert = true
33 | }
34 | }
35 | }
36 | }
37 |
38 | func addEventOnCalendar(completion: @escaping ((Bool) -> Void) ) {
39 | let eventStore = EKEventStore()
40 |
41 | eventStore.requestAccess(to: .event) { [weak self] (granted, error) in
42 | guard let self, error == nil else { return }
43 | let event = EKEvent(eventStore: eventStore)
44 | let formatter = DateFormatter.calendarFormatter
45 | event.title = self.event.title
46 | event.location = self.event.location
47 | event.startDate = formatter.date(from: self.event.startDate)
48 | event.endDate = formatter.date(from: self.event.endDate)
49 | event.calendar = eventStore.defaultCalendarForNewEvents
50 | do {
51 | try eventStore.save(event, span: .thisEvent)
52 | completion(true)
53 | } catch let error as NSError {
54 | print("failed to save event with error : \(error)")
55 | completion(false)
56 | }
57 | }
58 | }
59 | }
60 | }
61 |
62 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/StampView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StampView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Inho Choi on 2022/10/29.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct StampView: View {
11 | @StateObject var observed = Observed()
12 | @EnvironmentObject var envObserved: MainTabViewObserved
13 | let columns = [
14 | GridItem(.flexible(), spacing: 10),
15 | GridItem(.flexible())
16 | ]
17 |
18 | var body: some View {
19 |
20 | GeometryReader { proxy in
21 | NavigationView {
22 | ScrollView(showsIndicators: false) {
23 | ScrollViewReader { reader in
24 | LazyVGrid(
25 | columns: columns,
26 | spacing: 10
27 | ) {
28 | ForEach(observed.cards, id: \.event) { card in
29 | cardView(card: card, size: proxy.size)
30 | }
31 | }
32 | .padding(.horizontal, 14)
33 | }
34 | }
35 | .navigationTitle(Tab.stamp.title)
36 | .overlay {
37 | if observed.isLoading {
38 | loadingIndicator
39 | } else if !observed.isLoading, observed.cards.isEmpty {
40 | emptyCardView
41 | .padding(36)
42 | }
43 | }
44 | }
45 | }
46 | }
47 | }
48 |
49 | private extension StampView {
50 |
51 | @ViewBuilder var loadingIndicator: some View {
52 | ProgressView()
53 | .scaleEffect(1.5)
54 | .padding(30)
55 | .background(.ultraThinMaterial)
56 | .cornerRadius(10)
57 | }
58 |
59 | @ViewBuilder var emptyCardView: some View {
60 | ZStack {
61 | RoundedRectangle(cornerRadius: 30)
62 | .strokeBorder(Color(red: 0.78, green: 0.78, blue: 0.8), style: StrokeStyle(lineWidth: 2, dash: [10]))
63 | Text("아직 참여한 행사가 없습니다.")
64 | .foregroundColor(.gray)
65 | }
66 | }
67 |
68 | @ViewBuilder func cardView(card: Card, size: CGSize) -> some View {
69 | card.image
70 | .resizable()
71 | .aspectRatio(contentMode: .fill)
72 | .shadow(color: Color.black.opacity(0.1), radius: 10, y: 4)
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/AsyncSwift/Managers/KeyChainManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // KeyChain.swift
3 | // AsyncSwift
4 | //
5 | // Created by Inho Choi on 2022/09/15.
6 | //
7 |
8 | import UIKit
9 |
10 | final class KeyChainManager {
11 | let stampKey = "AsyncSwiftStamp"
12 |
13 | @discardableResult func addItem(key: Any, pwd: Any) -> Bool {
14 | let addQuery: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
15 | kSecAttrAccount: key,
16 | kSecValueData: (pwd as AnyObject).data(using: String.Encoding.utf8.rawValue) as Any]
17 | let status = SecItemAdd(addQuery as CFDictionary, nil)
18 |
19 | switch status {
20 | case errSecSuccess:
21 | return true
22 | case errSecDuplicateItem:
23 | return updateItem(value: pwd, key: key)
24 | default:
25 | print("addItem Error : \(status.description))")
26 | return false
27 | }
28 | }
29 |
30 | func getItem(key: Any) -> Any? {
31 | let getQuery: [CFString: Any] = [
32 | kSecClass: kSecClassGenericPassword,
33 | kSecAttrAccount: key,
34 | kSecReturnAttributes: true,
35 | kSecReturnData: true
36 | ]
37 | var item: CFTypeRef?
38 | let result = SecItemCopyMatching(getQuery as CFDictionary, &item)
39 |
40 | if result == errSecSuccess,
41 | let existingItem = item as? [String: Any],
42 | let data = existingItem[kSecValueData as String] as? Data,
43 | let password = String(data: data, encoding: .utf8) {
44 | return password
45 | }
46 | print("getItem Error : \(result.description)")
47 | return nil
48 | }
49 |
50 | func updateItem(value: Any, key: Any) -> Bool {
51 | let prevQuery: [CFString: Any] = [
52 | kSecClass: kSecClassGenericPassword,
53 | kSecAttrAccount: key
54 | ]
55 | let updateQuery: [CFString: Any] = [kSecValueData: (value as AnyObject).data(using: String.Encoding.utf8.rawValue) as Any]
56 |
57 | let result: Bool = {
58 | let status = SecItemUpdate(prevQuery as CFDictionary, updateQuery as CFDictionary)
59 | return status == errSecSuccess
60 | }()
61 |
62 | return result
63 | }
64 |
65 | func deleteItem(key: String) -> Bool {
66 | let deleteQuery: [CFString: Any] = [
67 | kSecClass: kSecClassGenericPassword,
68 | kSecAttrAccount: key
69 | ]
70 | let status = SecItemDelete(deleteQuery as CFDictionary)
71 | if status == errSecSuccess {
72 | return true
73 | } else {
74 | print("deleteItem Error : \(status.description)")
75 | return false
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/swift,firebase,xcode
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,firebase,xcode
3 |
4 | ### Custom ###
5 | **/.DS_Store
6 |
7 |
8 | ### Firebase ###
9 | .idea
10 | **/node_modules/*
11 | **/.firebaserc
12 |
13 | ### Firebase Patch ###
14 | .runtimeconfig.json
15 | .firebase/
16 |
17 | ### Swift ###
18 | # Xcode
19 | #
20 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
21 |
22 | ## User settings
23 | xcuserdata/
24 |
25 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
26 | *.xcscmblueprint
27 | *.xccheckout
28 |
29 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
30 | build/
31 | DerivedData/
32 | *.moved-aside
33 | *.pbxuser
34 | !default.pbxuser
35 | *.mode1v3
36 | !default.mode1v3
37 | *.mode2v3
38 | !default.mode2v3
39 | *.perspectivev3
40 | !default.perspectivev3
41 |
42 | ## Obj-C/Swift specific
43 | *.hmap
44 |
45 | ## App packaging
46 | *.ipa
47 | *.dSYM.zip
48 | *.dSYM
49 |
50 | ## Playgrounds
51 | timeline.xctimeline
52 | playground.xcworkspace
53 |
54 | # Swift Package Manager
55 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
56 | # Packages/
57 | # Package.pins
58 | # Package.resolved
59 | # *.xcodeproj
60 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
61 | # hence it is not needed unless you have added a package configuration file to your project
62 | # .swiftpm
63 |
64 | .build/
65 |
66 | # CocoaPods
67 | # We recommend against adding the Pods directory to your .gitignore. However
68 | # you should judge for yourself, the pros and cons are mentioned at:
69 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
70 | # Pods/
71 | # Add this line if you want to avoid checking in source code from the Xcode workspace
72 | # *.xcworkspace
73 |
74 | # Carthage
75 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
76 | # Carthage/Checkouts
77 |
78 | Carthage/Build/
79 |
80 | # Accio dependency management
81 | Dependencies/
82 | .accio/
83 |
84 | # fastlane
85 | # It is recommended to not store the screenshots in the git repo.
86 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
87 | # For more information about the recommended setup visit:
88 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
89 |
90 | fastlane/report.xml
91 | fastlane/Preview.html
92 | fastlane/screenshots/**/*.png
93 | fastlane/test_output
94 |
95 | # Code Injection
96 | # After new code Injection tools there's a generated folder /iOSInjectionProject
97 | # https://github.com/johnno1962/injectionforxcode
98 |
99 | iOSInjectionProject/
100 |
101 | ### Xcode ###
102 |
103 | ## Xcode 8 and earlier
104 |
105 | ### Xcode Patch ###
106 | *.xcodeproj/*
107 | !*.xcodeproj/project.pbxproj
108 | !*.xcodeproj/xcshareddata/
109 | !*.xcworkspace/contents.xcworkspacedata
110 | /*.gcno
111 | **/xcshareddata/WorkspaceSettings.xcsettings
112 |
113 | # End of https://www.toptal.com/developers/gitignore/api/swift,firebase,xcode
114 |
--------------------------------------------------------------------------------
/AsyncSwift/Managers/FirebaseManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FirebaseManager.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/11/03.
6 | //
7 |
8 | import Firebase
9 | import Foundation
10 |
11 | final class FirebaseManager: ObservableObject {
12 | static let shared = FirebaseManager()
13 | let db = Firestore.firestore()
14 | private init() { }
15 | }
16 |
17 | extension FirebaseManager {
18 | func createUser(user: User) {
19 | let docRef = db.collection("users").document(user.id)
20 | let docData: [String: Any] = [
21 | "id": user.id,
22 | "name": user.name,
23 | "nickname": user.nickname,
24 | "role": user.role,
25 | "description": user.description,
26 | "linkedInURL": user.linkedInURL,
27 | "profileURL": user.profileURL,
28 | "friends": []
29 | ]
30 | docRef.setData(docData) { error in
31 | if let error = error {
32 | // TODO: error 일 경우 Alert Message 보내기
33 | print("Error writing document: \(error)")
34 | } else {
35 | print("Document successfully written")
36 | }
37 | }
38 | }
39 |
40 | func getUserBy(id: String, completion: @escaping (User) -> Void) {
41 | let docRef = db.collection("users").document(id)
42 | docRef.getDocument { (document, error) in
43 | guard error == nil,
44 | let document = document,
45 | document.exists,
46 | let data = document.data(),
47 | let id = data["id"] as? String, // TODO : Optional 에서 변경하기
48 | let name = data["name"] as? String,
49 | let nickname = data["nickname"] as? String,
50 | let role = data["role"] as? String,
51 | let description = data["description"] as? String,
52 | let linkedInURL = data["linkedInURL"] as? String,
53 | let profileURL = data["profileURL"] as? String,
54 | let friends = data["friends"] as? [String]
55 | else { return }
56 |
57 | let user = User(
58 | id: id,
59 | name: name,
60 | nickname: nickname,
61 | role: role,
62 | description: description,
63 | linkedInURL: linkedInURL,
64 | profileURL: profileURL,
65 | friends: friends
66 | )
67 | completion(user)
68 | }
69 | }
70 |
71 | func editUser(user: User) {
72 | let docRef = db.collection("users").document(user.id)
73 | let docData: [String: Any] = [
74 | "id": user.id,
75 | "name": user.name,
76 | "nickname": user.nickname,
77 | "role": user.role,
78 | "description": user.description,
79 | "linkedInURL": user.linkedInURL,
80 | "profileURL": user.profileURL,
81 | "friends": user.friends
82 | ]
83 |
84 | docRef.setData(docData) { error in
85 | if let error = error {
86 | // TODO: error 일 경우 Alert Message 보내기
87 | print("Error writing document: \(error)")
88 | } else {
89 | print("Document successfully editted")
90 | }
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/ProfileView/ProfileFriendsListViewObserved.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileFriendsListViewObserved.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/11/03.
6 | //
7 |
8 | import CodeScanner
9 | import Combine
10 | import SwiftUI
11 |
12 | @MainActor
13 | final class ProfileFriendsListViewObserved: ObservableObject {
14 | @Binding var inActive: Bool
15 | @Published var isShowingUserDetail = false {
16 | didSet {
17 | if isShowingUserDetail == false {
18 | DispatchQueue.main.async { [weak self] in
19 | guard let self = self else { return }
20 | self.inActive = false
21 | }
22 | }
23 | }
24 | }
25 | @Published var isLoading = true
26 | @Published var isShowingScanner = false
27 | @Published var isShowingScanErrorAlert = false
28 | @Published var friendsList: [User] = []
29 | @Published var scannedFriend: User = .init()
30 |
31 | var user: User
32 |
33 | init(inActive: Binding, user: User) {
34 | self._inActive = inActive
35 | self.user = user
36 | }
37 |
38 | func onAppear() {
39 | Task {
40 | await getFriendsByID()
41 | isLoading = false
42 | }
43 | }
44 |
45 | func didTapXButton() {
46 | isShowingScanner = false
47 | }
48 |
49 | func handleScan(result: Result) {
50 | switch result {
51 | case .success(let success):
52 | let uuidString = success.string
53 | handleScanSuccess(id: uuidString)
54 | case .failure(_):
55 | isShowingScanner = false
56 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
57 | guard let self = self else { return }
58 | self.isShowingScanErrorAlert = true
59 | }
60 | }
61 | }
62 | }
63 |
64 | private extension ProfileFriendsListViewObserved {
65 | func handleScanSuccess(id: String) {
66 | guard UUID(uuidString: id) != nil
67 | else {
68 | isShowingScanner = false
69 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
70 | guard let self = self else { return }
71 | self.isShowingScanErrorAlert = true
72 | }
73 | return
74 | }
75 | Task {
76 | user.friends.append(id)
77 | FirebaseManager.shared.editUser(user: self.user)
78 | await getFriendByID(id: id)
79 | isShowingScanner = false
80 | isShowingUserDetail = true
81 | }
82 | }
83 |
84 | func getFriendByID(id: String) async {
85 | FirebaseManager.shared.getUserBy(id: id) { [weak self] user in
86 | guard let self = self else { return }
87 | self.scannedFriend = user
88 | }
89 | }
90 |
91 | func getFriendsByID() async {
92 | friendsList = []
93 | for friendID in self.user.friends {
94 | FirebaseManager.shared.getUserBy(id: friendID) { [weak self] user in
95 | guard let self = self else { return }
96 | self.friendsList.append(user)
97 | }
98 | }
99 | }
100 |
101 | func isNewFriend(id: String) -> Bool {
102 | return !user.friends.contains(id)
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/SessionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SessionView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/09/08.
6 | //
7 |
8 | import SwiftUI
9 | import SDWebImageSwiftUI
10 |
11 | struct SessionView: View {
12 |
13 | let session: Session
14 | let speakerImageSize: CGFloat = 80
15 |
16 | var body: some View {
17 | ScrollView {
18 | Group {
19 | customDivider
20 | .padding(.top, 10)
21 | .padding(.bottom, 4)
22 | sessionDetail
23 | }
24 | .background(.white)
25 | speakerDetail
26 | }
27 | .navigationTitle("Session")
28 | }
29 | }
30 |
31 | private extension SessionView {
32 |
33 | var sessionDetail: some View {
34 | VStack(alignment: .leading, spacing: 0) {
35 | HStack {
36 | Text(session.title)
37 | .font(.title3)
38 | .fontWeight(.semibold)
39 | .padding(.vertical, 24)
40 | Spacer(minLength: 0)
41 | }
42 | VStack(alignment: .leading, spacing: 8) {
43 | ForEach(session.description, id: \.self) { paragraph in
44 | Text(paragraph.content)
45 | }
46 | }
47 | .padding(.bottom, 80)
48 | }
49 | .frame(width: UIScreen.main.bounds.width - 32)
50 | }
51 |
52 | var speakerDetail: some View {
53 |
54 | HStack(spacing: 0) {
55 | VStack(alignment: .leading, spacing: 4) {
56 |
57 | WebImage(url: URL(string: session.speaker.imageURL))
58 | .resizable()
59 | .placeholder {
60 | Image(systemName: "person.crop.circle.fill")
61 | .resizable()
62 | .frame(width: speakerImageSize, height: speakerImageSize)
63 | .opacity(0.04)
64 | }
65 | .transition(.fade)
66 | .frame(width: speakerImageSize, height: speakerImageSize)
67 | .scaledToFit()
68 | .clipShape(Circle())
69 | .padding(.vertical, 24)
70 |
71 | VStack(alignment: .leading, spacing: 2) {
72 | Text("\(session.speaker.name) 님")
73 | .font(.headline)
74 | Text(session.speaker.role)
75 | .font(.caption2)
76 | }
77 | Text(session.speaker.description)
78 | .font(.footnote)
79 | }
80 | .padding(.horizontal, 32)
81 | .padding(.bottom, 60)
82 |
83 | Spacer()
84 | }
85 | .background(Color.speakerBackground)
86 | }
87 | }
88 |
89 | struct SessionView_Previews: PreviewProvider {
90 | static var previews: some View {
91 | SessionView(
92 | session: .init(
93 | id: 0,
94 | title: "[Event] 사이드 프로젝트가 메인 JOB이 되기까지의 이야기",
95 | description: [
96 | .init(content: "사이드 프로젝트가 메인 JOB이 되기까지 이야기")
97 | ],
98 | speaker: .init(
99 | name: "박성은",
100 | imageURL: "https://github.com/Async-Swift/jsonstorage/blob/main/Images/Speaker/syncswift2023/hyeonjung.png?raw=true",
101 | role: "북적 스튜디오 | iOS Developer",
102 | description: "iOS 개발자 입니다."
103 | )
104 | )
105 | )
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/ProfileView/ProfileEditViewObserved.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileEditViewObserved.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/11/04.
6 | //
7 |
8 | import UIKit
9 |
10 | @MainActor
11 | final class ProfileEditViewObserved: ObservableObject {
12 |
13 | @Published var isShowingSuccessAlert = false
14 | @Published var isShowingFailureAlert = false
15 | @Published var isShowingInputFailureAlert = false
16 |
17 | @Published var user: User
18 | @Published var description = "" {
19 | didSet {
20 | if description.count >= 80 {
21 | description = oldValue
22 | }
23 | }
24 | }
25 | @Published var linkedInURL = "" {
26 | didSet {
27 | print(self.verifyURL(urlString: profileURL))
28 | self.isLinkedinURLValidated = self.verifyURL(urlString: linkedInURL)
29 | }
30 | }
31 | @Published var profileURL = "" {
32 | didSet {
33 | print(self.verifyURL(urlString: profileURL))
34 | self.isProfileURLValidated = self.verifyURL(urlString: profileURL)
35 | }
36 | }
37 | var isLinkedinURLValidated = true
38 | var isProfileURLValidated = true
39 |
40 | init(user: User) {
41 | self.description = user.description
42 | self.linkedInURL = user.linkedInURL
43 | self.profileURL = user.profileURL
44 | self.user = user
45 | }
46 |
47 | func didTapRegisterButton() {
48 | register()
49 | }
50 |
51 | func isButtonAvailable() -> Bool {
52 | !user.name.isEmpty && !user.role.isEmpty
53 | }
54 | }
55 |
56 | private extension ProfileEditViewObserved {
57 | func register() {
58 | guard isButtonAvailable() else { return }
59 | if !linkedInURL.isEmpty && !profileURL.isEmpty {
60 | if isLinkedinURLValidated && isProfileURLValidated {
61 | handleSuccess()
62 | } else {
63 | showFailureAlert()
64 | }
65 | } else if !linkedInURL.isEmpty {
66 | if isLinkedinURLValidated {
67 | handleSuccess()
68 | } else {
69 | showFailureAlert()
70 | }
71 | } else if !profileURL.isEmpty {
72 | if isProfileURLValidated {
73 | handleSuccess()
74 | } else {
75 | showFailureAlert()
76 | }
77 | } else {
78 | handleSuccess()
79 | }
80 | }
81 |
82 | func handleSuccess() {
83 | Task {
84 | await editUser()
85 | showSuccessAlert()
86 | }
87 | }
88 |
89 | func showSuccessAlert() {
90 | DispatchQueue.main.async { [weak self] in
91 | guard let self = self else { return }
92 | self.isShowingSuccessAlert = true
93 | }
94 | }
95 |
96 | func showFailureAlert() {
97 | DispatchQueue.main.async { [weak self] in
98 | guard let self = self else { return }
99 | self.isShowingInputFailureAlert = true
100 | }
101 | }
102 |
103 | func editUser() async {
104 | let user = User(
105 | id: user.id,
106 | name: user.name,
107 | nickname: user.nickname,
108 | role: user.role,
109 | description: description,
110 | linkedInURL: linkedInURL,
111 | profileURL: profileURL,
112 | friends: user.friends
113 | )
114 | FirebaseManager.shared.editUser(user: user)
115 | }
116 |
117 | func verifyURL (urlString: String?) -> Bool {
118 | guard let urlString = urlString,
119 | let url = NSURL(string: urlString)
120 | else { return false }
121 | return UIApplication.shared.canOpenURL(url as URL)
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/AsyncSwift/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/09/06.
6 | //
7 |
8 | import SwiftUI
9 | import Firebase
10 | import UserNotifications
11 |
12 | @available(iOS 10, *)
13 | extension AppDelegate: UNUserNotificationCenterDelegate {
14 |
15 | // WHILE APP IS ON FOREGROUND
16 | func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
17 | let userInfo = notification.request.content.userInfo
18 | if let messageID = userInfo[gcmMessageIDKey]{
19 | print("Message ID : ", messageID)
20 | }
21 | if let data1 = userInfo[data1Key]{
22 | print("data1 : ", data1)
23 | }
24 | if let data2 = userInfo[data2Key]{
25 | print("data2 : ", data2)
26 | }
27 | if let apsData = userInfo[aps]{
28 | print("apsData : ", apsData)
29 | }
30 | completionHandler([[.banner, .badge, .sound]])
31 |
32 | print("MESSAGE RECIEVED ON FOREGROUND")
33 | }
34 |
35 | // EXECUTE WHEN USER CLICKS NOTIFICATION WHILE APP IS ON BACKGROUND
36 | func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
37 | let userInfo = response.notification.request.content.userInfo
38 | if let messageID = userInfo[gcmMessageIDKey]{
39 | print("Message ID from userNotificationCenter didRecieve : ", messageID)
40 | }
41 | completionHandler()
42 |
43 | }
44 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
45 | print("앱이 APNS에 등록되었음")
46 | Messaging.messaging().apnsToken = deviceToken
47 | }
48 | func application(_ application: UIApplication, didFailToContinueUserActivityWithType userActivityType: String, error: Error) {
49 | print("APNS가 등록에 실패 하였음")
50 | }
51 | }
52 |
53 | extension AppDelegate: MessagingDelegate {
54 | func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
55 | let deviceToken: [String:String] = ["token": fcmToken ?? ""]
56 | print("Device Token : ", deviceToken)
57 | }
58 | }
59 |
60 | class AppDelegate: NSObject, UIApplicationDelegate {
61 |
62 | let gcmMessageIDKey = "gcm.message_id"
63 | let aps = "aps"
64 | let data1Key = "DATA1"
65 | let data2Key = "DATA2"
66 |
67 | // Register for remote notifications
68 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
69 | FirebaseApp.configure()
70 | Messaging.messaging().delegate = self
71 |
72 | if #available(iOS 10.0, *){
73 | UNUserNotificationCenter.current().delegate = self
74 | let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
75 | UNUserNotificationCenter.current().requestAuthorization(
76 | options: authOptions, completionHandler: {_, _ in})
77 | } else {
78 | let settings: UIUserNotificationSettings = UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
79 | application.registerUserNotificationSettings(settings)
80 | }
81 | application.registerForRemoteNotifications()
82 | return true
83 | }
84 |
85 | func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
86 | if let messageID = userInfo[gcmMessageIDKey]{
87 | print("Message ID : \(messageID)")
88 | }
89 | print("userInfo : ", userInfo)
90 | completionHandler(UIBackgroundFetchResult.newData)
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/ProfileView/ProfileRegisterViewObserved.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileRegisterViewObserved.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/10/28.
6 | //
7 |
8 | import Combine
9 | import SwiftUI
10 |
11 | @MainActor
12 | final class ProfileRegisterViewObserved: ObservableObject {
13 | @Binding var hasRegisteredProfile: Bool
14 | @Binding var userID: String?
15 |
16 | @Published var isShowingSuccessAlert = false
17 | @Published var isShowingFailureAlert = false
18 | @Published var isShowingInputFailureAlert = false
19 |
20 | @Published var name = ""
21 | @Published var nickname = ""
22 | @Published var role = ""
23 | @Published var description = "" {
24 | didSet {
25 | if description.count >= 80 {
26 | description = oldValue
27 | }
28 | }
29 | }
30 | @Published var linkedInURL = "" {
31 | didSet {
32 | self.isLinkedinURLValidated = self.verifyURL(urlString: linkedInURL)
33 | }
34 | }
35 | @Published var profileURL = "" {
36 | didSet {
37 | self.isProfileURLValidated = self.verifyURL(urlString: profileURL)
38 | }
39 | }
40 | var isLinkedinURLValidated = false
41 | var isProfileURLValidated = false
42 |
43 | init(hasRegisteredProfile: Binding, userID: Binding) {
44 | self._hasRegisteredProfile = hasRegisteredProfile
45 | self._userID = userID
46 | }
47 |
48 | func didTapRegisterButton() {
49 | register()
50 | }
51 |
52 | func isButtonAvailable() -> Bool {
53 | if !name.isEmpty && !role.isEmpty {
54 | return true
55 | } else {
56 | return false
57 | }
58 | }
59 | }
60 |
61 | private extension ProfileRegisterViewObserved {
62 |
63 | func register() {
64 | guard isButtonAvailable() else { return }
65 | if !linkedInURL.isEmpty && !profileURL.isEmpty {
66 | if isLinkedinURLValidated && isProfileURLValidated {
67 | handleSuccess()
68 | } else {
69 | showFailureAlert()
70 | }
71 | } else if !linkedInURL.isEmpty {
72 | if isLinkedinURLValidated {
73 | handleSuccess()
74 | } else {
75 | showFailureAlert()
76 | }
77 | } else if !profileURL.isEmpty {
78 | if isProfileURLValidated {
79 | handleSuccess()
80 | } else {
81 | showFailureAlert()
82 | }
83 | } else {
84 | handleSuccess()
85 | }
86 | }
87 |
88 | func handleSuccess() {
89 | Task {
90 | await createUser()
91 | hasRegisteredProfile = true
92 | showSuccessAlert()
93 | }
94 | }
95 |
96 | func showSuccessAlert() {
97 | DispatchQueue.main.async { [weak self] in
98 | guard let self = self else { return }
99 | self.isShowingSuccessAlert = true
100 | }
101 | }
102 |
103 | func showFailureAlert() {
104 | DispatchQueue.main.async { [weak self] in
105 | guard let self = self else { return }
106 | self.isShowingInputFailureAlert = true
107 | }
108 | }
109 |
110 |
111 | func createUser() async {
112 | let userId = UUID().uuidString
113 | let user = User(
114 | id: userId,
115 | name: name,
116 | nickname: nickname,
117 | role: role,
118 | description: description,
119 | linkedInURL: linkedInURL,
120 | profileURL: profileURL,
121 | friends: []
122 | )
123 | self.userID = userId
124 | FirebaseManager.shared.createUser(user: user)
125 | }
126 |
127 | func verifyURL (urlString: String?) -> Bool {
128 | guard let urlString = urlString,
129 | let url = NSURL(string: urlString)
130 | else { return false }
131 | return UIApplication.shared.canOpenURL(url as URL)
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/EventDetailView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EventDetailView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/09/08.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EventDetailView: View {
11 |
12 | @ObservedObject var observed: Observed
13 |
14 | init(event: Event) {
15 | observed = Observed(event: event)
16 | }
17 |
18 | var body: some View {
19 | ScrollView {
20 | VStack(alignment: .leading, spacing: 0) {
21 | customDivider
22 | .padding(.top, 10)
23 | description
24 | customDivider
25 | information
26 | Spacer()
27 | }
28 | }
29 | .navigationTitle(observed.event.detailTitle)
30 | .confirmationDialog("", isPresented: $observed.isShowingSheet, titleVisibility: .hidden) {
31 | if let naverMapURL = URL(string: observed.event.addressURLs.naverMapURL) {
32 | Link("네이버 지도로 길 찾기", destination: naverMapURL)
33 | }
34 | if let kakaoMapURL = URL(string: observed.event.addressURLs.kakaoMapURL) {
35 | Link("카카오맵으로 길 찾기", destination: kakaoMapURL)
36 | }
37 | }
38 | }
39 | }
40 |
41 | private extension EventDetailView {
42 |
43 | var description: some View {
44 | VStack(alignment: .leading, spacing: 8) {
45 | Text(observed.event.subject)
46 | .fontWeight(.bold)
47 | .font(.title3)
48 | ForEach(observed.event.description, id:\.self) { paragraph in
49 | Text(paragraph.content)
50 | .font(.body)
51 | }
52 | Text(observed.event.hashTags)
53 | .padding(.top, 8)
54 | .foregroundColor(.gray)
55 | .font(.body)
56 | }
57 | .padding(.horizontal, 24)
58 | .padding(.vertical, 30)
59 | }
60 |
61 | var information: some View {
62 | VStack(alignment: .leading, spacing: 40) {
63 | VStack(alignment: .leading, spacing: 8) {
64 | Text("\(Image(systemName: "calendar")) Date and time")
65 | .font(.title3)
66 | .fontWeight(.semibold)
67 | Text("\(observed.event.date)\n\(observed.event.time)")
68 | .font(.body)
69 | Button("캘린더에 추가") {
70 | observed.isShowingAddEventConfirmationAlert = true
71 | }
72 | .alert("'AsyncSwift'이(가) 사용자의 캘린터에 접근하려고 합니다.", isPresented: $observed.isShowingAddEventConfirmationAlert, actions: {
73 | Button("허용 안 함") { observed.isShowingAddEventConfirmationAlert = false }
74 | Button("확인") { observed.additionConfirmed() }
75 | }, message: {
76 | Text("사용자의 '캘린더'에 접근하도록 허용합니다.")
77 | })
78 | .alert("일정 등록 완료", isPresented: $observed.isShowingAddEventSuccessAlert, actions: {
79 | Button("확인", role: .cancel) { }
80 | }, message: {
81 | Text("세미나 일정이 캘린더에 추가되었습니다.")
82 | })
83 | .alert("일정 등록 실패", isPresented: $observed.isShowingAddEventFailureAlert, actions: {
84 | Button("다시 시도", role: .cancel) { }
85 | }, message: {
86 | Text("등록에 오류가 발생했습니다.\n다시 시도하십시오.")
87 | })
88 | }
89 | VStack(alignment: .leading, spacing: 8) {
90 | Text("\(Image(systemName: "location.fill")) Location")
91 | .font(.title3)
92 | .fontWeight(.semibold)
93 | VStack(alignment: .leading) {
94 | Text(observed.event.location)
95 | Text(observed.event.address)
96 | }
97 | Button {
98 | observed.isShowingSheet = true
99 | } label: {
100 | Text("지도로 길찾기")
101 | }
102 | }
103 | }
104 | .padding(.horizontal, 24)
105 | .padding(.vertical, 30)
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/ProfileView/ProfileViewObserved.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileView+Observed.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/10/16.
6 | //
7 |
8 | import CodeScanner
9 | import CoreImage.CIFilterBuiltins
10 | import Combine
11 | import UIKit
12 |
13 | @MainActor
14 | final class ProfileViewObserved: ObservableObject {
15 | @Published var hasRegisteredProfile = false
16 | @Published var isLoading = true
17 | @Published var isShowingFriends = false
18 | @Published var isShowingEdit = false
19 | @Published var isShowingScanner = false
20 | @Published var isShowingUserDetail = false
21 | @Published var isShowingFailureAlert = false
22 | @Published var isShowingScanErrorAlert = false
23 | @Published var user: User = .init()
24 | @Published var scannedFriend: User = .init()
25 | private let keyChainManager = KeyChainManager()
26 |
27 | var userID: String? {
28 | didSet {
29 | let _ = keyChainManager.addItem(key: "userID", pwd: userID ?? "")
30 | }
31 | }
32 |
33 | init() {
34 | guard let userid = keyChainManager.getItem(key: "userID") else { return }
35 | self.hasRegisteredProfile = true
36 | self.userID = userid as? String
37 | }
38 |
39 | func onAppear() {
40 | guard hasRegisteredProfile else { return }
41 | Task {
42 | await getUserByID()
43 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
44 | guard let self = self else { return }
45 | self.isLoading = false
46 | }
47 | }
48 | }
49 |
50 | func didTapCloseButton() {
51 | isShowingScanner = false
52 | }
53 |
54 | func getQRCodeImage() -> UIImage {
55 | let context = CIContext()
56 | let filter = CIFilter.qrCodeGenerator()
57 | let data = Data(userID?.utf8 ?? "".utf8)
58 | filter.setValue(data, forKey: "inputMessage")
59 | guard let qrCodeImage = filter.outputImage,
60 | let qrCodeImage = context.createCGImage(qrCodeImage, from: qrCodeImage.extent)
61 | else { return UIImage(systemName: "xmark") ?? UIImage() }
62 | return UIImage(cgImage: qrCodeImage)
63 | }
64 |
65 | func handleScan(result: Result) {
66 | switch result {
67 | case .success(let success):
68 | let uuidString = success.string
69 | handleScanSuccess(id: uuidString)
70 | case .failure(_):
71 | isShowingScanner = false
72 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
73 | guard let self = self else { return }
74 | self.isShowingScanErrorAlert = true
75 | }
76 | }
77 | }
78 | }
79 |
80 | private extension ProfileViewObserved {
81 | func handleScanSuccess(id: String) {
82 | guard (UUID(uuidString: id)) != nil
83 | else {
84 | isShowingScanner = false
85 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
86 | guard let self = self else { return }
87 | self.isShowingScanErrorAlert = true
88 | }
89 | return
90 | }
91 | guard isNewFriend(id: id)
92 | else {
93 | isShowingScanner = false
94 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
95 | guard let self = self else { return }
96 | self.isShowingFailureAlert = true
97 | }
98 | return
99 | }
100 | Task {
101 | user.friends.append(id)
102 | FirebaseManager.shared.editUser(user: self.user)
103 | await getFriendByID(id: id)
104 | isShowingScanner = false
105 | isShowingUserDetail = true
106 | }
107 | }
108 |
109 | func getUserByID() async {
110 | FirebaseManager.shared.getUserBy(id: self.userID ?? "") { [weak self] user in
111 | guard let self = self else { return }
112 | self.user = user
113 | }
114 | }
115 |
116 | func getFriendByID(id: String) async {
117 | FirebaseManager.shared.getUserBy(id: id) { [weak self] user in
118 | guard let self = self else { return }
119 | self.scannedFriend = user
120 | }
121 | }
122 |
123 | func isNewFriend(id: String) -> Bool {
124 | return !user.friends.contains(id)
125 | }
126 | }
127 |
128 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/Profile/ProfileFriendDetailView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileFriendDetailView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/11/02.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ProfileFriendDetailView: View {
11 | @ObservedObject var observed: ProfileFriendDetailViewObserved
12 |
13 | init(
14 | previous: PreviousView,
15 | inActive: Binding,
16 | user: User,
17 | friend: User
18 | ) {
19 | observed = ProfileFriendDetailViewObserved(
20 | inActive: inActive,
21 | user: user,
22 | friend: friend,
23 | previous: previous
24 | )
25 | }
26 |
27 | var body: some View {
28 | VStack(alignment: .leading, spacing: 0) {
29 | customDivider
30 | .padding(.top, 10)
31 | userDetail
32 | Spacer()
33 | linkButtons
34 | }
35 | .navigationTitle(observed.friend.name)
36 | .navigationBarItems(trailing: navigationBarTrailingButton)
37 | .fullScreenCover(isPresented: $observed.isShowingProfileSheet, content: {
38 | if let url = observed.profileURL {
39 | SafariView(url: url)
40 | .ignoresSafeArea()
41 | }
42 | })
43 | .fullScreenCover(isPresented: $observed.isShowingLinkedInSheet, content: {
44 | if let url = observed.linkedInURL {
45 | SafariView(url: url)
46 | .ignoresSafeArea()
47 | }
48 | })
49 | .alert(isPresented: $observed.isShowingConfirmAlert) {
50 | Alert(
51 | title: Text("삭제"),
52 | message: Text("유저 친구 목록에서 삭제하시겠습니까?"),
53 | primaryButton: .destructive(Text("삭제")) {
54 | observed.didConfirmDelete()
55 | },
56 | secondaryButton: .cancel(Text("취소")) {
57 | observed.isShowingConfirmAlert = false
58 | }
59 | )
60 | }
61 | }
62 | }
63 |
64 | private extension ProfileFriendDetailView {
65 | var userDetail: some View {
66 | VStack(alignment: .leading, spacing: 0) {
67 | Text(observed.friend.nickname)
68 | .fontWeight(.semibold)
69 | .font(.system(size: 20))
70 | Text(observed.friend.role)
71 | .fontWeight(.semibold)
72 | .font(.system(size: 20))
73 | .foregroundColor(.profileFontGrayForeground)
74 | .padding(.bottom, 24)
75 | Text(observed.friend.description)
76 | }
77 | .padding(.horizontal, 24)
78 | .padding(.top, 28)
79 | }
80 |
81 | var linkButtons: some View {
82 | VStack {
83 | profileButton
84 | linkedInButton
85 | .padding(.bottom, 16)
86 | }
87 | .padding(.horizontal)
88 | }
89 |
90 | var profileButton: some View {
91 | Button {
92 | if observed.hasProfileURL {
93 | observed.isShowingProfileSheet = true
94 | }
95 | } label: {
96 | Text("Profile URL")
97 | .font(.headline)
98 | .foregroundColor(.white)
99 | .frame(minWidth: 0, maxWidth: .infinity, maxHeight: 68)
100 | .background(observed.hasProfileURL ? Color.seminarOrange : Color.inActiveButtonBackground)
101 | .cornerRadius(15)
102 | }
103 | }
104 |
105 | var linkedInButton: some View {
106 | Button {
107 | if observed.hasLinkedInURL {
108 | observed.isShowingLinkedInSheet = true
109 | }
110 | } label: {
111 | Text("LinkedIn")
112 | .font(.headline)
113 | .foregroundColor(.white)
114 | .frame(minWidth: 0, maxWidth: .infinity, maxHeight: 68)
115 | .background(observed.hasLinkedInURL ? Color.linkedInBlueBackground : Color.inActiveButtonBackground)
116 | .cornerRadius(15)
117 | }
118 | }
119 |
120 | @ViewBuilder
121 | var navigationBarTrailingButton: some View {
122 | switch observed.previous {
123 | case .ProfileView:
124 | doneButton
125 | case .ListView:
126 | deleteButton
127 | }
128 | }
129 |
130 | var doneButton: some View {
131 | Button {
132 | observed.didTapDoneButton()
133 | } label: {
134 | Text("Done")
135 | }
136 | }
137 |
138 | var deleteButton: some View {
139 | Button {
140 | observed.didTapDeleteButton()
141 | } label: {
142 | Text("Delete")
143 | }
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/TicketingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TicketView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/09/06.
6 | //
7 |
8 | import SwiftUI
9 | import SDWebImageSwiftUI
10 |
11 | struct TicketingView: View {
12 | @StateObject private var observed = Observed()
13 |
14 | var body: some View {
15 |
16 | NavigationView {
17 | ScrollView {
18 | VStack(spacing: 30) {
19 | if let upcomingEvent = observed.ticketing?.upcomingEvent {
20 | makeUpcomingEventView(from: upcomingEvent)
21 | .ticketingViewStyle()
22 | } else {
23 | skeletonView
24 | .aspectRatio(2.75, contentMode: .fill)
25 | .ticketingViewStyle()
26 | }
27 |
28 | switch observed.hasAvailableTicket {
29 | case true:
30 | ticketingView
31 | .ticketingViewStyle()
32 | case false:
33 | emptyTicketingView
34 | .ticketingViewStyle()
35 | }
36 | }
37 | .padding(.horizontal)
38 | .padding(.vertical, 30)
39 |
40 | }
41 | .navigationTitle("Ticketing")
42 | }
43 | .onAppear { observed.getTicketingData() }
44 | }
45 | }
46 |
47 | fileprivate extension TicketingView {
48 | struct TicketingViewStyle: ViewModifier {
49 | func body(content: Content) -> some View {
50 | content
51 | .cornerRadius(8.0)
52 | .shadow(
53 | color: Color(.sRGB, red: 0, green: 0, blue: 0, opacity: 0.15),
54 | radius: 20, x: 0, y: 4
55 | )
56 | }
57 | }
58 | }
59 |
60 | fileprivate extension View {
61 | func ticketingViewStyle() -> some View {
62 | modifier(TicketingView.TicketingViewStyle())
63 | }
64 | }
65 |
66 | private extension TicketingView {
67 | var skeletonView: some View {
68 | LinearGradient(
69 | colors: [.skeletonBackground, .white],
70 | startPoint: .topLeading,
71 | endPoint: .bottomTrailing
72 | )
73 | .animation(.linear(duration: 3.0), value: 1.0)
74 | }
75 |
76 | @ViewBuilder var ticketingView: some View {
77 |
78 | if let url = URL(string: observed.ticketing?.currentTicket?.ticketingURL ?? "") {
79 | Link(destination: url) {
80 | WebImage(url: URL(string: observed.ticketing?.currentTicket?.ticketingImageURL ?? ""))
81 | .resizable()
82 | .placeholder {
83 | skeletonView
84 | .aspectRatio(0.85, contentMode: .fill)
85 | }
86 | .scaledToFill()
87 | .transition(.opacity.animation(.easeOut))
88 | }
89 | .disabled(observed.isTicketingLinkDisabled)
90 | }
91 | }
92 |
93 | var emptyTicketingView: some View {
94 | Text("현재 판매중인 티켓이 없습니다.")
95 | .fontWeight(.bold)
96 | .font(.body)
97 | .foregroundColor(.emptyTicketViewForeground)
98 | .frame(maxWidth: .infinity, maxHeight: .infinity)
99 | .aspectRatio(0.85, contentMode: .fill)
100 | .background(Color.emptyTicketViewBackground)
101 | }
102 |
103 | @ViewBuilder
104 | func makeUpcomingEventView(from upcomingEvent: Ticketing.UpcomingEvent) -> some View {
105 | HStack(alignment: .top, spacing: 0.0) {
106 | VStack(alignment: .leading, spacing: 15.0) {
107 | Text(upcomingEvent.headerTitle)
108 | .fontWeight(.bold)
109 | .font(.caption2)
110 | VStack(alignment: .leading, spacing: 5.0) {
111 | Text(upcomingEvent.title)
112 | .fontWeight(.bold)
113 | .font(.title2)
114 | Text(upcomingEvent.subscription)
115 | .fontWeight(.bold)
116 | .font(.subheadline)
117 | }
118 | }
119 | Spacer()
120 | }
121 | .frame(maxWidth: .infinity)
122 | .padding(
123 | EdgeInsets(top: 15.0, leading: 12.0, bottom: 24.0, trailing: 12.0)
124 | )
125 | .foregroundColor(.white)
126 | .background(
127 | LinearGradient(
128 | colors: [upcomingEvent.backgroundGradientStartColor, upcomingEvent.backgroundGradientEndColor],
129 | startPoint: .leading,
130 | endPoint: .trailing
131 | )
132 | )
133 | }
134 | }
135 |
136 | struct TicketView_Previews: PreviewProvider {
137 | static var previews: some View {
138 | TicketingView()
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/AsyncSwift/Observed/StampView+Observed.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StampView+Observed.swift
3 | // AsyncSwift
4 | //
5 | // Created by Inho Choi on 2022/09/09.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 |
11 | extension StampView {
12 | final class Observed: ObservableObject {
13 | @Published var cards: [Card] = []
14 | @Published var events = [String]()
15 | @Published var currentIndex = 0
16 | @Published var isLoading = true
17 | private let keyChainManager = KeyChainManager()
18 | private let cardInterval: CGFloat = (UIScreen.main.bounds.width - 32) * 56 / 358
19 | private let cardSize: CGFloat = UIScreen.main.bounds.width - 32
20 | private var cancenllable = Set()
21 |
22 | init() {
23 | fetchStampsImages()
24 | }
25 |
26 | private func getEvents() -> [String] {
27 | let pwRaw = keyChainManager.getItem(key: keyChainManager.stampKey) as? String
28 | guard let convertedStringArray = pwRaw?.convertToStringArray() else { return [] }
29 | self.events = convertedStringArray.reversed()
30 | return events
31 | }
32 |
33 | /// Storage에 저장되어 있는 Stamp Image를 가져오는 함수이다.
34 | /// -
35 | private func fetchStampsImages() {
36 |
37 | let events = getEvents()
38 | guard !events.isEmpty else { return isLoading = false }
39 |
40 | events.enumerated().forEach { [weak self] in
41 | guard let self else { return }
42 | let event = $0.element
43 | let index = $0.offset
44 | let url = URL(string: GitHubStorageURL.stampImage(event))!
45 |
46 | URLSession.shared.dataTaskPublisher(for: url)
47 | .map(\.data)
48 | .tryMap {
49 | guard let image = UIImage(data: $0) else {
50 | throw URLError(.badURL)
51 | }
52 | return Card(
53 | originalPosition: self.cardInterval * CGFloat(index),
54 | image: Image(uiImage: image),
55 | event: event
56 | )
57 | }
58 | .receive(on: RunLoop.main)
59 | .sink(receiveCompletion: { _ in
60 |
61 | }, receiveValue: { [weak self] card in
62 | self?.cards.append(card)
63 | if index == events.count - 1 {
64 | self?.isLoading = false
65 | }
66 | })
67 | .store(in: &cancenllable)
68 | }
69 | }
70 |
71 | func isAvailableURL(url: URL) async -> Bool {
72 | // URL Example = https://asyncswift.info?tab=Stamp&event=Conference001
73 | // URL Example = https://asyncswift.info?tab=Event
74 |
75 | if URLComponents(url: url, resolvingAgainstBaseURL: true)?.host == nil { return false }
76 | var queries = [String: String]()
77 | for item in URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems ?? [] {
78 | queries[item.name] = item.value
79 | }
80 |
81 | do {
82 | let stamp = try await fetchCurrentStamp()
83 |
84 | if queries["tab"] == Tab.stamp.rawValue {
85 | guard let queryEvent = queries["event"] else { return false }
86 | if stamp.title == queryEvent {
87 | let pwRaw = keyChainManager.getItem(key: keyChainManager.stampKey) as? String
88 | var pw: [String] = pwRaw?.convertToStringArray() ?? []
89 | pw.append(queryEvent)
90 |
91 | if keyChainManager.addItem(key: keyChainManager.stampKey, pwd: pw.description) {
92 | fetchStampsImages()
93 | }
94 | }
95 | }
96 | } catch {
97 | print(error.localizedDescription)
98 | return false
99 | }
100 | return true
101 | }
102 |
103 | private func fetchCurrentStamp() async throws -> Stamp {
104 | guard let url = URL(string: "https://raw.githubusercontent.com/Async-Swift/jsonstorage/main/currentEvent.json") // MARK: URL 주소 확인 테스트용으로 되어 있음
105 | else { return .init(title: "error") }
106 |
107 | let request = URLRequest(url: url)
108 | let (data, response) = try await URLSession.shared.data(for: request)
109 |
110 | guard let httpResponse = response as? HTTPURLResponse,
111 | httpResponse.statusCode == 200 else { return .init(title: "error")}
112 |
113 | let stamp = try JSONDecoder().decode(Stamp.self, from: data)
114 |
115 | return stamp
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/Profile/ProfileFriendsListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileFriendsListView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/11/02.
6 | //
7 |
8 | import CodeScanner
9 | import SwiftUI
10 |
11 | struct ProfileFriendsListView: View {
12 | @StateObject var observed: ProfileFriendsListViewObserved
13 |
14 | init(inActive: Binding, user: User) {
15 | _observed = StateObject(
16 | wrappedValue: ProfileFriendsListViewObserved(
17 | inActive: inActive,
18 | user: user
19 | ))
20 | }
21 |
22 | var body: some View {
23 | VStack(spacing: 0) {
24 | customDivider
25 | .padding(.top, 10)
26 | friendList
27 | }
28 | .navigationTitle("Friends")
29 | .fullScreenCover(
30 | isPresented: $observed.isShowingScanner,
31 | content: { scannerView }
32 | )
33 | .fullScreenCover(
34 | isPresented: $observed.isShowingUserDetail,
35 | content: { scannedFriendDetail }
36 | )
37 | .onAppear {
38 | observed.onAppear()
39 | }
40 | .alert("QR 등록 오류", isPresented: $observed.isShowingScanErrorAlert, actions: {
41 | Button("취소", role: .cancel) { observed.isShowingScanErrorAlert = false }
42 | }, message: {
43 | Text("등록할 수 없는 QR코드입니다.")
44 | })
45 | }
46 | }
47 |
48 | private extension ProfileFriendsListView {
49 | @ViewBuilder
50 | var friendList: some View {
51 | switch observed.isLoading {
52 | case true:
53 | loadingList
54 | case false:
55 | switch observed.user.friends.isEmpty {
56 | case true:
57 | emptyList
58 | case false:
59 | list
60 | }
61 | }
62 | }
63 |
64 | var emptyList: some View {
65 | VStack(spacing: 0) {
66 | Spacer()
67 | Text("등록된 프로필이 없습니다.")
68 | .foregroundColor(.profileGray)
69 | .padding(.bottom, 17)
70 | Button {
71 | observed.isShowingScanner = true
72 | } label: {
73 | Text("프로필 스캔하기")
74 | .foregroundColor(.seminarOrange)
75 | .font(.headline)
76 | }
77 | Spacer()
78 | }
79 | }
80 |
81 | var loadingList: some View {
82 | ScrollView {
83 | ForEach(0.. some View {
107 | NavigationLink {
108 | ProfileFriendDetailView(
109 | previous: .ListView,
110 | inActive: $observed.inActive,
111 | user: observed.user,
112 | friend: friend
113 | )
114 | } label: {
115 | HStack {
116 | Text("\(friend.name) | \(friend.nickname)")
117 | .font(.headline)
118 | Spacer()
119 | Image(systemName: "chevron.forward")
120 | }
121 | .foregroundColor(.black)
122 | .padding(.horizontal, 19)
123 | .padding(.vertical, 23)
124 | .frame(maxWidth: .infinity, maxHeight: 56)
125 | .background(Color.buttonBackground)
126 | .cornerRadius(15)
127 | }
128 | }
129 |
130 | var scannerView: some View {
131 | VStack {
132 | ZStack {
133 | Text("코드스캔")
134 | HStack {
135 | Spacer()
136 | Button {
137 | observed.didTapXButton()
138 | } label: {
139 | Text("Done")
140 | }
141 | .padding()
142 | }
143 | }
144 | .frame(height: 51)
145 | CodeScannerView(
146 | codeTypes: [.qr],
147 | simulatedData: "6A8254C2-1054-4A5B-9F30-602684D329F9",
148 | completion: observed.handleScan
149 | )
150 | HStack {
151 | Text("QR코드를 스캔해 보세요. 프로필 상세 정보를 확인할 수 있습니다.")
152 | .font(.caption2)
153 | }
154 | .frame(height: 70)
155 | }
156 | }
157 |
158 | var scannedFriendDetail: some View {
159 | NavigationView {
160 | VStack {
161 | ProfileFriendDetailView(
162 | previous: .ProfileView,
163 | inActive: $observed.isShowingUserDetail,
164 | user: observed.user,
165 | friend: observed.scannedFriend
166 | )
167 | }
168 | }
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/AsyncSwift.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "abseil-cpp-binary",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/google/abseil-cpp-binary.git",
7 | "state" : {
8 | "revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c",
9 | "version" : "1.2022062300.0"
10 | }
11 | },
12 | {
13 | "identity" : "cocoalumberjack",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git",
16 | "state" : {
17 | "revision" : "67ec5818a757aba4d7c534e21a905d878d128dbf",
18 | "version" : "3.8.1"
19 | }
20 | },
21 | {
22 | "identity" : "codescanner",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/twostraws/CodeScanner",
25 | "state" : {
26 | "revision" : "bf5d7087015620b250ee6c865b3c9039fc159d1a",
27 | "version" : "2.3.3"
28 | }
29 | },
30 | {
31 | "identity" : "firebase-ios-sdk",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/firebase/firebase-ios-sdk.git",
34 | "state" : {
35 | "revision" : "837d4af6ead57cec1fc38007892500d3139c7556",
36 | "version" : "10.16.0"
37 | }
38 | },
39 | {
40 | "identity" : "googleappmeasurement",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/google/GoogleAppMeasurement.git",
43 | "state" : {
44 | "revision" : "56f681586ff006a7982b53dc94082eea31971acf",
45 | "version" : "10.16.0"
46 | }
47 | },
48 | {
49 | "identity" : "googledatatransport",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/google/GoogleDataTransport.git",
52 | "state" : {
53 | "revision" : "aae45a320fd0d11811820335b1eabc8753902a40",
54 | "version" : "9.2.5"
55 | }
56 | },
57 | {
58 | "identity" : "googleutilities",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/google/GoogleUtilities.git",
61 | "state" : {
62 | "revision" : "c38ce365d77b04a9a300c31061c5227589e5597b",
63 | "version" : "7.11.5"
64 | }
65 | },
66 | {
67 | "identity" : "grpc-binary",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/google/grpc-binary.git",
70 | "state" : {
71 | "revision" : "a673bc2937fbe886dd1f99c401b01b6d977a9c98",
72 | "version" : "1.49.1"
73 | }
74 | },
75 | {
76 | "identity" : "gtm-session-fetcher",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/google/gtm-session-fetcher.git",
79 | "state" : {
80 | "revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd",
81 | "version" : "3.1.1"
82 | }
83 | },
84 | {
85 | "identity" : "interop-ios-for-google-sdks",
86 | "kind" : "remoteSourceControl",
87 | "location" : "https://github.com/google/interop-ios-for-google-sdks.git",
88 | "state" : {
89 | "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648",
90 | "version" : "100.0.0"
91 | }
92 | },
93 | {
94 | "identity" : "leveldb",
95 | "kind" : "remoteSourceControl",
96 | "location" : "https://github.com/firebase/leveldb.git",
97 | "state" : {
98 | "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
99 | "version" : "1.22.2"
100 | }
101 | },
102 | {
103 | "identity" : "nanopb",
104 | "kind" : "remoteSourceControl",
105 | "location" : "https://github.com/firebase/nanopb.git",
106 | "state" : {
107 | "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692",
108 | "version" : "2.30909.0"
109 | }
110 | },
111 | {
112 | "identity" : "promises",
113 | "kind" : "remoteSourceControl",
114 | "location" : "https://github.com/google/promises.git",
115 | "state" : {
116 | "revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e",
117 | "version" : "2.3.1"
118 | }
119 | },
120 | {
121 | "identity" : "sdwebimage",
122 | "kind" : "remoteSourceControl",
123 | "location" : "https://github.com/SDWebImage/SDWebImage.git",
124 | "state" : {
125 | "revision" : "936f1c7067728d16c362ba4fb93c17df78b5fd79",
126 | "version" : "5.18.2"
127 | }
128 | },
129 | {
130 | "identity" : "sdwebimageswiftui",
131 | "kind" : "remoteSourceControl",
132 | "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git",
133 | "state" : {
134 | "revision" : "e837c37d45449fbd3b4745c10c5b5274e73edead",
135 | "version" : "2.2.3"
136 | }
137 | },
138 | {
139 | "identity" : "svgkit",
140 | "kind" : "remoteSourceControl",
141 | "location" : "https://github.com/SVGKit/SVGKit.git",
142 | "state" : {
143 | "revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666",
144 | "version" : "3.0.0"
145 | }
146 | },
147 | {
148 | "identity" : "swift-log",
149 | "kind" : "remoteSourceControl",
150 | "location" : "https://github.com/apple/swift-log.git",
151 | "state" : {
152 | "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed",
153 | "version" : "1.5.3"
154 | }
155 | },
156 | {
157 | "identity" : "swift-protobuf",
158 | "kind" : "remoteSourceControl",
159 | "location" : "https://github.com/apple/swift-protobuf.git",
160 | "state" : {
161 | "revision" : "3c54ab05249f59f2c6641dd2920b8358ea9ed127",
162 | "version" : "1.24.0"
163 | }
164 | }
165 | ],
166 | "version" : 2
167 | }
168 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/EventView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScheduleView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/09/06.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EventView: View {
11 |
12 | @StateObject var observed = EventViewObserved()
13 |
14 | var body: some View {
15 | NavigationView {
16 | ScrollView {
17 | if observed.isLoading {
18 | VStack(spacing: 0) {
19 | onLoadingHeader
20 | VStack(alignment: .leading, spacing: 0) {
21 | ForEach(observed.onLoadingCells, id: \.self) { _ in
22 | onLoadingCell
23 | }
24 | }
25 | }
26 | } else {
27 | Header
28 | LazyVStack {
29 | ForEach(observed.event.sessions) { session in
30 | makeSessionCell(for: session)
31 | }
32 | }
33 | }
34 | }
35 | .navigationTitle(Tab.event.title)
36 | .onAppear { observed.getEventData() }
37 | }
38 | }
39 | }
40 |
41 | private extension EventView {
42 |
43 | var onLoadingHeader: some View {
44 | HStack {
45 | VStack(alignment: .leading, spacing: 9) {
46 | Rectangle()
47 | .frame(width: 202, height: 30)
48 | .cornerRadius(4)
49 | .foregroundColor(.gray)
50 | .opacity(0.8)
51 | HStack {
52 | Rectangle()
53 | .frame(width: 151, height: 21)
54 | .cornerRadius(13)
55 | .foregroundColor(.gray)
56 | Rectangle()
57 | .frame(width: 70, height: 21)
58 | .cornerRadius(13)
59 | .foregroundColor(.gray)
60 | }
61 | .opacity(0.4)
62 | .padding(.bottom, 2)
63 | Rectangle()
64 | .frame(width: 106, height: 13)
65 | .cornerRadius(4)
66 | .foregroundColor(.gray)
67 | .opacity(0.2)
68 | }
69 | Spacer()
70 | }
71 | .padding(.horizontal, 16)
72 | .padding(.vertical, 32)
73 | }
74 |
75 | var Header: some View {
76 | VStack(alignment: .leading, spacing: 8) {
77 | Text(observed.event.subject)
78 | .font(.title)
79 | .fontWeight(.bold)
80 | HStack {
81 | Text(observed.event.title)
82 | .font(.caption2)
83 | .fontWeight(.bold)
84 | .foregroundColor(.white)
85 | .padding(.vertical, 4)
86 | .padding(.horizontal, 8)
87 | .background(Color.seminarOrange)
88 | .cornerRadius(20)
89 | Text(observed.eventStatus.rawValue)
90 | .font(.caption2)
91 | .fontWeight(.bold)
92 | .foregroundColor(observed.eventStatus.statusColor)
93 | .padding(.vertical, 4)
94 | .padding(.horizontal, 8)
95 | .overlay(
96 | RoundedRectangle(cornerRadius: 20)
97 | .stroke(observed.eventStatus.statusColor, lineWidth: 1)
98 | )
99 | Spacer()
100 | }
101 | NavigationLink {
102 | EventDetailView(event: observed.event)
103 | } label: {
104 | Text("\(observed.event.type) 살펴보기 \(Image(systemName: "arrow.right"))")
105 | .font(.footnote)
106 | .fontWeight(.bold)
107 | }
108 | }
109 | .padding(.horizontal, 16)
110 | .padding(.vertical, 30)
111 | }
112 |
113 | @ViewBuilder
114 | var onLoadingCell: some View {
115 | VStack(alignment: .leading, spacing: 0) {
116 | customDivider
117 | VStack(alignment: .leading, spacing: 0) {
118 | Rectangle()
119 | .frame(width: 250, height: 20)
120 | .cornerRadius(4)
121 | .foregroundColor(.gray)
122 | .opacity(0.4)
123 | .padding(.bottom, 4)
124 | Rectangle()
125 | .frame(width: 70, height: 20)
126 | .cornerRadius(4)
127 | .foregroundColor(.gray)
128 | .opacity(0.2)
129 | }
130 | .padding(.horizontal)
131 | .padding(.bottom, 27)
132 | .padding(.top, 31)
133 | }
134 | }
135 |
136 | @ViewBuilder
137 | func makeSessionCell(for session: Session) -> some View {
138 | NavigationLink {
139 | SessionView(session: session)
140 | } label: {
141 | VStack {
142 | customDivider
143 | HStack {
144 | VStack(alignment: .leading, spacing: 2) {
145 | Text(session.title)
146 | .font(.headline)
147 | .foregroundColor(.black)
148 | .multilineTextAlignment(.leading)
149 | Text("\(session.speaker.name) 님")
150 | .font(.body)
151 | .foregroundColor(.black)
152 | }
153 | .padding(.vertical, 30)
154 | Spacer()
155 | VStack {
156 | Image(systemName: "chevron.right")
157 | .font(Font.system(size: 30, weight: .light))
158 | .foregroundColor(.black)
159 | }
160 | }
161 | .padding(.horizontal)
162 | }
163 | }
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/Profile/ProfileEditView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileEditView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/11/04.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ProfileEditView: View {
11 | @Environment(\.dismiss) private var dismiss
12 | @ObservedObject var observed: ProfileEditViewObserved
13 |
14 | init(user: User) {
15 | observed = ProfileEditViewObserved(user: user)
16 | }
17 |
18 | var body: some View {
19 | VStack(spacing: 0) {
20 | Header
21 | ScrollView {
22 | VStack(spacing: 0) {
23 | nameTextField
24 | nicknameTextField
25 | jobTitleTextField
26 | descriptionTextField
27 | linkedInTextField
28 | privateURLTextField
29 | }
30 | }
31 | Spacer()
32 | }
33 | .navigationBarTitle("Edit", displayMode: .large)
34 | .toolbar {
35 | submitButton
36 | }
37 | }
38 | }
39 |
40 | private extension ProfileEditView {
41 | var Header: some View {
42 | VStack(spacing: 0) {
43 | VStack(spacing: 0) {
44 | HStack(spacing: 0) {
45 | Text("프로필을 수정")
46 | .font(.title3)
47 | .fontWeight(.bold)
48 | .frame(minHeight: 24)
49 | .padding(.bottom, 3)
50 | Spacer()
51 | }
52 | HStack(spacing: 0) {
53 | Text("특수문자는 입력할 수 없어요")
54 | .font(.footnote)
55 | .frame(minHeight: 18)
56 | Spacer()
57 | }
58 | }
59 | .padding(.top, 38)
60 | .padding(.bottom, 20)
61 | .padding(.horizontal)
62 | customDivider
63 | }
64 | .alert("프로필 등록 완료", isPresented: $observed.isShowingSuccessAlert, actions: {
65 | Button("확인", role: .cancel) { dismiss() }
66 | }, message: {
67 | Text("개인 프로필이 수정되었습니다.")
68 | })
69 | .alert("프로필 등록 오류", isPresented: $observed.isShowingFailureAlert, actions: {
70 | Button("다시 시도", role: .cancel) { }
71 | }, message: {
72 | Text("입력되지 않은 내용이 있습니다.\n필수 입력란을 확인해주세요.")
73 | })
74 | .alert("프로필 입력 오류", isPresented: $observed.isShowingInputFailureAlert, actions: {
75 | Button("다시 시도", role: .cancel) { }
76 | }, message: {
77 | Text("확인되지 않은 주소입니다.\nURL을 확인해주세요.")
78 | })
79 | }
80 |
81 |
82 | var nameTextField: some View {
83 | VStack(spacing: 0) {
84 | HStack(spacing: 0) {
85 | Text("이름")
86 | .profileInputTitle
87 | TextField("", text: $observed.user.name)
88 | .profileTextField
89 | .placeholder(
90 | when: observed.user.name.isEmpty,
91 | text: "Required",
92 | isTextField: true
93 | )
94 | Spacer()
95 | }
96 | customDivider
97 | .padding(.top, 15)
98 | }
99 | .padding(.leading)
100 | .padding(.top, 23)
101 | }
102 |
103 | var nicknameTextField: some View {
104 | VStack(spacing: 0) {
105 | HStack(spacing: 0) {
106 | Text("닉네임")
107 | .profileInputTitle
108 | TextField("", text: $observed.user.nickname)
109 | .profileTextField
110 | .placeholder(
111 | when: observed.user.nickname.isEmpty,
112 | text: "Optional",
113 | isTextField: true
114 | )
115 | Spacer()
116 | }
117 | customDivider
118 | .padding(.top, 15)
119 | }
120 | .padding(.leading)
121 | .padding(.top, 23)
122 | }
123 |
124 | var jobTitleTextField: some View {
125 | VStack(spacing: 0) {
126 | HStack(spacing: 0) {
127 | Text("직군")
128 | .profileInputTitle
129 | TextField("", text: $observed.user.role)
130 | .profileTextField
131 | .placeholder(
132 | when: observed.user.role.isEmpty,
133 | text: "Required",
134 | isTextField: true
135 | )
136 | Spacer()
137 | }
138 | customDivider
139 | .padding(.top, 15)
140 | }
141 | .padding(.leading)
142 | .padding(.top, 23)
143 | }
144 |
145 | var descriptionTextField: some View {
146 | VStack(spacing: 0) {
147 | HStack(alignment: .top, spacing: 0) {
148 | Text("소개")
149 | .profileInputTitle
150 | if #available(iOS 16.0, *) {
151 | TextEditor(text: $observed.description)
152 | .profileTextEditor
153 | .scrollContentBackground(.hidden)
154 | .placeholder(
155 | when: observed.description.isEmpty,
156 | text: "Optional, 80자 이내",
157 | isTextField: false
158 | )
159 | .offset(x: -2, y: -8)
160 | } else {
161 | TextEditor(text: $observed.description)
162 | .profileTextEditor
163 | .placeholder(
164 | when: observed.description.isEmpty,
165 | text: "Optional, 80자 이내",
166 | isTextField: false
167 | )
168 | }
169 |
170 | }
171 | customDivider
172 | .padding(.top, 15)
173 | }
174 | .padding(.leading)
175 | .padding(.top, 23)
176 | .frame(height: 91)
177 | }
178 |
179 | var linkedInTextField: some View {
180 | VStack(spacing: 0) {
181 | HStack(spacing: 0) {
182 | Text("링크드인 프로필 URL")
183 | .profileInputTitle
184 | Spacer()
185 | }
186 | .padding(.top, 20)
187 | HStack(spacing: 0) {
188 | TextField("", text: $observed.linkedInURL)
189 | .profileTextField
190 | .placeholder(
191 | when: observed.linkedInURL.isEmpty,
192 | text: "Optional",
193 | isTextField: true
194 | )
195 | }
196 | .padding(.top, 5)
197 | customDivider
198 | .padding(.top, 15)
199 | }
200 | .padding(.leading)
201 | }
202 |
203 | var privateURLTextField: some View {
204 | VStack(spacing: 0) {
205 | HStack(spacing: 0) {
206 | Text("개인 페이지 URL")
207 | .profileInputTitle
208 | Spacer()
209 | }
210 | .padding(.top, 20)
211 | HStack(spacing: 0) {
212 | TextField("", text: $observed.profileURL)
213 | .profileTextField
214 | .placeholder(
215 | when: observed.profileURL.isEmpty,
216 | text: "Optional",
217 | isTextField: true
218 | )
219 | }
220 | .padding(.top, 5)
221 | }
222 | .padding(.leading)
223 | }
224 |
225 | var submitButton: some View {
226 | Button {
227 | if observed.isButtonAvailable() {
228 | observed.didTapRegisterButton()
229 | }
230 | } label: {
231 | Text("Save")
232 | .foregroundColor(
233 | observed.isButtonAvailable() ?
234 | Color.accentColor :
235 | Color.unavailableButtonBackground
236 | )
237 | }
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/Profile/ProfileRegisterView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileRegisterView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/10/28.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ProfileRegisterView: View {
11 | @Environment(\.dismiss) private var dismiss
12 | @ObservedObject var observed: ProfileRegisterViewObserved
13 |
14 | init(hasRegisteredProfile: Binding, userID: Binding) {
15 | observed = ProfileRegisterViewObserved(
16 | hasRegisteredProfile: hasRegisteredProfile,
17 | userID: userID
18 | )
19 | }
20 |
21 | var body: some View {
22 | VStack(spacing: 0) {
23 | Header
24 | ScrollView {
25 | VStack(spacing: 0) {
26 | nameTextField
27 | nicknameTextField
28 | jobTitleTextField
29 | descriptionTextField
30 | linkedInTextField
31 | privateURLTextField
32 | }
33 | }
34 | Spacer()
35 | }
36 | .navigationBarTitle("Register", displayMode: .large)
37 | .toolbar {
38 | submitButton
39 | }
40 | }
41 | }
42 |
43 | private extension ProfileRegisterView {
44 | var Header: some View {
45 | VStack(spacing: 0) {
46 | VStack(spacing: 0) {
47 | HStack(spacing: 0) {
48 | Text("프로필을 등록해주세요")
49 | .font(.title3)
50 | .fontWeight(.bold)
51 | .frame(minHeight: 24)
52 | .padding(.bottom, 3)
53 | Spacer()
54 | }
55 | HStack(spacing: 0) {
56 | Text("특수문자는 입력할 수 없어요")
57 | .font(.footnote)
58 | .frame(minHeight: 18)
59 | Spacer()
60 | }
61 | }
62 | .padding(.top, 38)
63 | .padding(.bottom, 20)
64 | .padding(.horizontal)
65 | customDivider
66 | }
67 | .alert("프로필 등록 완료", isPresented: $observed.isShowingSuccessAlert, actions: {
68 | Button("확인", role: .cancel) { dismiss() }
69 | }, message: {
70 | Text("개인 프로필이 추가되었습니다.")
71 | })
72 | .alert("프로필 등록 오류", isPresented: $observed.isShowingFailureAlert, actions: {
73 | Button("다시 시도", role: .cancel) { }
74 | }, message: {
75 | Text("입력되지 않은 내용이 있습니다.\n필수 입력란을 확인해주세요.")
76 | })
77 | .alert("프로필 입력 오류", isPresented: $observed.isShowingInputFailureAlert, actions: {
78 | Button("다시 시도", role: .cancel) { }
79 | }, message: {
80 | Text("확인되지 않은 주소입니다.\nURL을 확인해주세요.")
81 | })
82 | }
83 |
84 |
85 | var nameTextField: some View {
86 | VStack(spacing: 0) {
87 | HStack(spacing: 0) {
88 | Text("이름")
89 | .profileInputTitle
90 | TextField("", text: $observed.name)
91 | .profileTextField
92 | .placeholder(
93 | when: observed.name.isEmpty,
94 | text: "Required",
95 | isTextField: true
96 | )
97 | Spacer()
98 | }
99 | customDivider
100 | .padding(.top, 15)
101 | }
102 | .padding(.leading)
103 | .padding(.top, 23)
104 | }
105 |
106 | var nicknameTextField: some View {
107 | VStack(spacing: 0) {
108 | HStack(spacing: 0) {
109 | Text("닉네임")
110 | .profileInputTitle
111 | TextField("", text: $observed.nickname)
112 | .profileTextField
113 | .placeholder(
114 | when: observed.nickname.isEmpty,
115 | text: "Optional",
116 | isTextField: true
117 | )
118 | Spacer()
119 | }
120 | customDivider
121 | .padding(.top, 15)
122 | }
123 | .padding(.leading)
124 | .padding(.top, 23)
125 | }
126 |
127 | var jobTitleTextField: some View {
128 | VStack(spacing: 0) {
129 | HStack(spacing: 0) {
130 | Text("직군")
131 | .profileInputTitle
132 | TextField("", text: $observed.role)
133 | .profileTextField
134 | .placeholder(
135 | when: observed.role.isEmpty,
136 | text: "Required",
137 | isTextField: true
138 | )
139 | Spacer()
140 | }
141 | customDivider
142 | .padding(.top, 15)
143 | }
144 | .padding(.leading)
145 | .padding(.top, 23)
146 | }
147 |
148 | var descriptionTextField: some View {
149 | VStack(spacing: 0) {
150 | HStack(alignment: .top, spacing: 0) {
151 | Text("소개")
152 | .profileInputTitle
153 | if #available(iOS 16.0, *) {
154 | TextEditor(text: $observed.description)
155 | .profileTextEditor
156 | .scrollContentBackground(.hidden)
157 | .placeholder(
158 | when: observed.description.isEmpty,
159 | text: "Optional, 80자 이내",
160 | isTextField: false
161 | )
162 | .offset(x: -2, y: -8)
163 | } else {
164 | TextEditor(text: $observed.description)
165 | .profileTextEditor
166 | .placeholder(
167 | when: observed.description.isEmpty,
168 | text: "Optional, 80자 이내",
169 | isTextField: false
170 | )
171 | }
172 |
173 | }
174 | customDivider
175 | .padding(.top, 15)
176 | }
177 | .padding(.leading)
178 | .padding(.top, 23)
179 | .frame(height: 91)
180 | }
181 |
182 | var linkedInTextField: some View {
183 | VStack(spacing: 0) {
184 | HStack(spacing: 0) {
185 | Text("링크드인 프로필 URL")
186 | .profileInputTitle
187 | Spacer()
188 | }
189 | .padding(.top, 20)
190 | HStack(spacing: 0) {
191 | TextField("", text: $observed.linkedInURL)
192 | .profileTextField
193 | .placeholder(
194 | when: observed.linkedInURL.isEmpty,
195 | text: "Optional",
196 | isTextField: true
197 | )
198 | }
199 | .padding(.top, 5)
200 | customDivider
201 | .padding(.top, 15)
202 | }
203 | .padding(.leading)
204 | }
205 |
206 | var privateURLTextField: some View {
207 | VStack(spacing: 0) {
208 | HStack(spacing: 0) {
209 | Text("개인 페이지 URL")
210 | .profileInputTitle
211 | Spacer()
212 | }
213 | .padding(.top, 20)
214 | HStack(spacing: 0) {
215 | TextField("", text: $observed.profileURL)
216 | .profileTextField
217 | .placeholder(
218 | when: observed.profileURL.isEmpty,
219 | text: "Optional",
220 | isTextField: true
221 | )
222 | }
223 | .padding(.top, 5)
224 | }
225 | .padding(.leading)
226 | }
227 |
228 | var submitButton: some View {
229 | Button {
230 | if observed.isButtonAvailable() {
231 | observed.didTapRegisterButton()
232 | }
233 | } label: {
234 | Text("Save")
235 | .foregroundColor(
236 | observed.isButtonAvailable() ?
237 | Color.accentColor :
238 | Color.unavailableButtonBackground
239 | )
240 | }
241 | }
242 | }
243 |
244 |
--------------------------------------------------------------------------------
/AsyncSwift/Views/Profile/ProfileView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileView.swift
3 | // AsyncSwift
4 | //
5 | // Created by Kim Insub on 2022/10/16.
6 | //
7 |
8 | import CodeScanner
9 | import SwiftUI
10 |
11 | // TODO
12 | // 1 : Enter 치면 다음 input focused 되도록 변경
13 |
14 | struct ProfileView: View {
15 |
16 | @StateObject var observed = ProfileViewObserved()
17 |
18 | var body: some View {
19 | NavigationView {
20 | VStack(spacing: 0) {
21 | header
22 | Spacer()
23 | if observed.hasRegisteredProfile {
24 | friendsListLinkButton
25 | }
26 | editProfileLinkButton
27 | }
28 | .navigationTitle("Profile")
29 | .navigationBarItems(trailing: codeScannerButton)
30 | .fullScreenCover(
31 | isPresented: $observed.isShowingScanner,
32 | content: { scannerView }
33 | )
34 | .fullScreenCover(
35 | isPresented: $observed.isShowingUserDetail,
36 | content: { scannedFriendDetail }
37 | )
38 | .onAppear {
39 | observed.onAppear()
40 | }
41 | }
42 | .alert("프로필 등록 오류", isPresented: $observed.isShowingFailureAlert, actions: {
43 | Button("확인", role: .cancel) { observed.isShowingFailureAlert = false }
44 | }, message: {
45 | Text("이미 등록된 프로필입니다.")
46 | })
47 | .alert("QR 등록 오류", isPresented: $observed.isShowingScanErrorAlert, actions: {
48 | Button("취소", role: .cancel) { observed.isShowingScanErrorAlert = false }
49 | }, message: {
50 | Text("등록할 수 없는 QR코드입니다.")
51 | })
52 | }
53 | }
54 |
55 | private extension ProfileView {
56 | @ViewBuilder
57 | var header: some View {
58 | if observed.hasRegisteredProfile {
59 | if observed.isLoading {
60 | hasRegisteredHeaderLoadingView
61 | } else {
62 | hasRegisteredHeader
63 | }
64 | } else {
65 | registerHeader
66 | }
67 | }
68 |
69 | var hasRegisteredHeaderLoadingView: some View {
70 | VStack(spacing: 0) {
71 | customDivider
72 | .padding(.top, 10)
73 | .padding(.bottom, 56)
74 | Rectangle()
75 | .frame(width: 130, height: 130)
76 | .foregroundColor(Color.skeletonQR)
77 | .padding(.bottom, 26)
78 | Rectangle()
79 | .frame(width: 165, height: 23)
80 | .foregroundColor(Color.skeletonName)
81 | .cornerRadius(4)
82 | .padding(.bottom, 7)
83 | Rectangle()
84 | .frame(width: 91, height: 16)
85 | .foregroundColor(Color.skeletonName)
86 | .cornerRadius(4)
87 | .padding(.bottom, 25)
88 | VStack(alignment: .leading, spacing: 0) {
89 | Rectangle()
90 | .frame(width: 309, height: 16)
91 | .foregroundColor(Color.skeletonDescription)
92 | .cornerRadius(4)
93 | .padding(.bottom, 3)
94 | Rectangle()
95 | .frame(width: 309, height: 16)
96 | .foregroundColor(Color.skeletonDescription)
97 | .cornerRadius(4)
98 | .padding(.bottom, 3)
99 | Rectangle()
100 | .frame(width: 221, height: 16)
101 | .foregroundColor(Color.skeletonDescription)
102 | .cornerRadius(4)
103 | }
104 | }
105 | }
106 |
107 | var hasRegisteredHeader: some View {
108 | VStack(spacing: 0) {
109 | customDivider
110 | .padding(.top, 10)
111 | .padding(.bottom, 55)
112 | Image(uiImage: observed.getQRCodeImage())
113 | .interpolation(.none)
114 | .resizable()
115 | .frame(width: 157, height: 157)
116 | .padding(.bottom, 40)
117 | Text("\(observed.user.name) | \(observed.user.nickname)")
118 | .font(.title3)
119 | .fontWeight(.semibold)
120 | .padding(.bottom, 4)
121 | Text("\(observed.user.role)")
122 | .font(.subheadline)
123 | .fontWeight(.semibold)
124 | .foregroundColor(.profileGray)
125 | .padding(.bottom, 18)
126 | Text("\(observed.user.description)")
127 | .font(.footnote)
128 | .padding(.horizontal, 43)
129 | }
130 | }
131 |
132 | var registerHeader: some View {
133 | VStack(spacing: 0) {
134 | Image("QRplaceholder")
135 | .frame(width: 157)
136 | .padding(.bottom, 50)
137 | Text("등록한 프로필이 없습니다.")
138 | .foregroundColor(.profileGray)
139 | .font(.body)
140 | .padding(.bottom, 17)
141 | registerLink
142 | }
143 | .padding(.top, 68)
144 | }
145 |
146 | var registerLink: some View {
147 | NavigationLink {
148 | ProfileRegisterView(
149 | hasRegisteredProfile: $observed.hasRegisteredProfile,
150 | userID: $observed.userID
151 | )
152 | } label: {
153 | Text("프로필 등록하기")
154 | .font(.headline)
155 | }
156 | }
157 |
158 | @ViewBuilder
159 | var friendsListLinkButton: some View {
160 | if observed.isLoading {
161 | Button { } label: {
162 | linkLabelButtonLabel(text: "Friends")
163 | .opacity(0.2)
164 | }
165 | .padding(.bottom, 16)
166 | } else {
167 | NavigationLink(
168 | destination: ProfileFriendsListView(
169 | inActive: $observed.isShowingFriends,
170 | user: observed.user),
171 | isActive: $observed.isShowingFriends,
172 | label: {
173 | Button {
174 | observed.isShowingFriends = true
175 | } label: {
176 | linkLabelButtonLabel(text: "Friends")
177 | }
178 | }
179 | )
180 | .padding(.bottom, 16)
181 | }
182 | }
183 |
184 | @ViewBuilder
185 | var editProfileLinkButton: some View {
186 | switch observed.hasRegisteredProfile {
187 | case true:
188 | switch observed.isLoading {
189 | case true:
190 | Button { } label: {
191 | linkLabelButtonLabel(text: "Edit Profile")
192 | .opacity(0.2)
193 | }
194 | .padding(.bottom, 32)
195 | case false:
196 | NavigationLink(
197 | destination: ProfileEditView(user: observed.user),
198 | isActive: $observed.isShowingEdit,
199 | label: {
200 | Button {
201 | observed.isShowingEdit = true
202 | } label: {
203 | linkLabelButtonLabel(text: "Edit Profile")
204 | }
205 | })
206 | .padding(.bottom, 32)
207 | }
208 | case false:
209 | NavigationLink {
210 | ProfileRegisterView(
211 | hasRegisteredProfile: $observed.hasRegisteredProfile,
212 | userID: $observed.userID
213 | )
214 | } label: {
215 | linkLabelButtonLabel(text: "Edit Profile")
216 | }
217 | .padding(.bottom, 32)
218 | }
219 | }
220 |
221 | @ViewBuilder
222 | var codeScannerButton: some View {
223 | if observed.hasRegisteredProfile && !observed.isLoading {
224 | Button {
225 | observed.isShowingScanner = true
226 | } label: {
227 | Image(systemName: "qrcode.viewfinder")
228 | }
229 | } else {
230 | Button {
231 |
232 | } label: {
233 | Image(systemName: "qrcode.viewfinder")
234 | .foregroundColor(.gray)
235 | }
236 | }
237 | }
238 |
239 | var scannerView: some View {
240 | VStack {
241 | ZStack {
242 | Text("코드스캔")
243 | HStack {
244 | Spacer()
245 | Button {
246 | observed.didTapCloseButton()
247 | } label: {
248 | Text("Done")
249 | }
250 | .padding()
251 | }
252 | }
253 | .frame(height: 51)
254 | CodeScannerView(
255 | codeTypes: [.qr],
256 | simulatedData: "1AA5CC09-6F7F-4EC4-A2BE-819B93362B7B",
257 | completion: observed.handleScan
258 | )
259 | HStack {
260 | Text("QR코드를 스캔해 보세요. 프로필 상세 정보를 확인할 수 있습니다.")
261 | .font(.caption2)
262 | }
263 | .frame(height: 70)
264 | }
265 | }
266 |
267 | func linkLabelButtonLabel(text: String) -> some View {
268 | HStack {
269 | Text(text)
270 | .font(.headline)
271 | Spacer()
272 | Image(systemName: "chevron.forward")
273 | }
274 | .foregroundColor(.black)
275 | .padding(.horizontal, 19)
276 | .padding(.vertical, 23)
277 | .frame(maxWidth: .infinity, maxHeight: 68)
278 | .background(Color.buttonBackground)
279 | .cornerRadius(15)
280 | .padding(.horizontal)
281 | }
282 |
283 | var scannedFriendDetail: some View {
284 | NavigationView {
285 | VStack {
286 | ProfileFriendDetailView(
287 | previous: .ProfileView,
288 | inActive: $observed.isShowingUserDetail,
289 | user: observed.user,
290 | friend: observed.scannedFriend
291 | )
292 | }
293 | }
294 | }
295 | }
296 |
--------------------------------------------------------------------------------
/AsyncSwift/Assets.xcassets/Seminar002StampFront.imageset/Seminar002StampFront.pdf:
--------------------------------------------------------------------------------
1 | %PDF-1.7
2 |
3 | 1 0 obj
4 | << /Length 2 0 R >>
5 | stream
6 | 0.519531 0 0.006348 -0.171875 0.513184 0.695801 d1
7 |
8 | endstream
9 | endobj
10 |
11 | 2 0 obj
12 | 51
13 | endobj
14 |
15 | 3 0 obj
16 | << /Length 4 0 R >>
17 | stream
18 | 0.403000 0 0.012000 -0.013000 0.391000 0.433000 d1
19 |
20 | endstream
21 | endobj
22 |
23 | 4 0 obj
24 | 51
25 | endobj
26 |
27 | 5 0 obj
28 | << /Length 6 0 R >>
29 | stream
30 | 0.671387 0 0.013184 0.000000 0.658691 0.717773 d1
31 |
32 | endstream
33 | endobj
34 |
35 | 6 0 obj
36 | 50
37 | endobj
38 |
39 | 7 0 obj
40 | << /Length 8 0 R >>
41 | stream
42 | 0.528809 0 0.028320 -0.010254 0.506348 0.566895 d1
43 |
44 | endstream
45 | endobj
46 |
47 | 8 0 obj
48 | 51
49 | endobj
50 |
51 | 9 0 obj
52 | << /Length 10 0 R >>
53 | stream
54 | 0.228027 0 0.046387 0.000000 0.181641 0.771484 d1
55 |
56 | endstream
57 | endobj
58 |
59 | 10 0 obj
60 | 50
61 | endobj
62 |
63 | 11 0 obj
64 | << /Length 12 0 R >>
65 | stream
66 | 0.335449 0 0.009277 -0.010254 0.302246 0.668457 d1
67 |
68 | endstream
69 | endobj
70 |
71 | 12 0 obj
72 | 51
73 | endobj
74 |
75 | 13 0 obj
76 | << /Length 14 0 R >>
77 | stream
78 | 0.646000 0 0.040000 0.000000 0.605000 0.448000 d1
79 |
80 | endstream
81 | endobj
82 |
83 | 14 0 obj
84 | 50
85 | endobj
86 |
87 | 15 0 obj
88 | << /Length 16 0 R >>
89 | stream
90 | 0.496582 0 0.027344 -0.010254 0.469238 0.565918 d1
91 |
92 | endstream
93 | endobj
94 |
95 | 16 0 obj
96 | 51
97 | endobj
98 |
99 | 17 0 obj
100 | << /Length 18 0 R >>
101 | stream
102 | 0.762695 0 0.010742 0.000000 0.751953 0.528320 d1
103 |
104 | endstream
105 | endobj
106 |
107 | 18 0 obj
108 | 50
109 | endobj
110 |
111 | 19 0 obj
112 | << /Length 20 0 R >>
113 | stream
114 | 0.617188 0 0.032715 -0.012207 0.584961 0.761719 d1
115 |
116 | endstream
117 | endobj
118 |
119 | 20 0 obj
120 | 51
121 | endobj
122 |
123 | 21 0 obj
124 | << /Length 22 0 R >>
125 | stream
126 | 0.455000 0 0.012000 -0.013000 0.414000 0.433000 d1
127 |
128 | endstream
129 | endobj
130 |
131 | 22 0 obj
132 | 51
133 | endobj
134 |
135 | 23 0 obj
136 | << /Length 24 0 R >>
137 | stream
138 | 0.558105 0 0.049805 0.000000 0.510742 0.578125 d1
139 |
140 | endstream
141 | endobj
142 |
143 | 24 0 obj
144 | 50
145 | endobj
146 |
147 | 25 0 obj
148 | << /Length 26 0 R >>
149 | stream
150 | 0.336914 0 0.012695 0.000000 0.308594 0.727539 d1
151 |
152 | endstream
153 | endobj
154 |
155 | 26 0 obj
156 | 50
157 | endobj
158 |
159 | 27 0 obj
160 | << /Length 28 0 R >>
161 | stream
162 | 0.465000 0 0.023000 0.000000 0.431000 0.646000 d1
163 |
164 | endstream
165 | endobj
166 |
167 | 28 0 obj
168 | 50
169 | endobj
170 |
171 | 29 0 obj
172 | << /Length 30 0 R >>
173 | stream
174 | 0.465000 0 0.015000 -0.016000 0.450000 0.654000 d1
175 |
176 | endstream
177 | endobj
178 |
179 | 30 0 obj
180 | 51
181 | endobj
182 |
183 | 31 0 obj
184 | << /Length 32 0 R >>
185 | stream
186 | 0.295000 0 0.040000 0.000000 0.299000 0.448000 d1
187 |
188 | endstream
189 | endobj
190 |
191 | 32 0 obj
192 | 50
193 | endobj
194 |
195 | 33 0 obj
196 | << /Length 34 0 R >>
197 | stream
198 | 0.334000 0 0.000000 0.000000 0.334000 1.000000 d1
199 |
200 | endstream
201 | endobj
202 |
203 | 34 0 obj
204 | 50
205 | endobj
206 |
207 | 35 0 obj
208 | << /Length 36 0 R >>
209 | stream
210 | 0.435000 0 0.040000 0.000000 0.394000 0.448000 d1
211 |
212 | endstream
213 | endobj
214 |
215 | 36 0 obj
216 | 50
217 | endobj
218 |
219 | 37 0 obj
220 | << /Length 38 0 R >>
221 | stream
222 | 0.204000 0 0.045000 0.000000 0.159000 0.663000 d1
223 |
224 | endstream
225 | endobj
226 |
227 | 38 0 obj
228 | 50
229 | endobj
230 |
231 | 39 0 obj
232 | << /Length 40 0 R >>
233 | stream
234 | 0.423000 0 0.019000 -0.016000 0.405000 0.658000 d1
235 |
236 | endstream
237 | endobj
238 |
239 | 40 0 obj
240 | 51
241 | endobj
242 |
243 | 41 0 obj
244 | [ 0.335449 0.336914 0.528809 0.519531 0.228027 0.762695 0.617188 0.496582 0.558105 0.671387 0.465000 0.465000 0.295000 0.334000 0.435000 0.455000 0.646000 0.204000 0.403000 0.423000 ]
245 | endobj
246 |
247 | 42 0 obj
248 | << /Length 43 0 R >>
249 | stream
250 | /CIDInit /ProcSet findresource begin
251 | 12 dict begin
252 | begincmap
253 | /CIDSystemInfo
254 | << /Registry (FigmaPDF)
255 | /Ordering (FigmaPDF)
256 | /Supplement 0
257 | >> def
258 | /CMapName /A-B-C def
259 | /CMapType 2 def
260 | 1 begincodespacerange
261 | <00>
262 | endcodespacerange
263 | 1 beginbfchar
264 | <00> <0074>
265 | endbfchar
266 | 1 beginbfchar
267 | <01> <0066>
268 | endbfchar
269 | 1 beginbfchar
270 | <02> <0063>
271 | endbfchar
272 | 1 beginbfchar
273 | <03> <0079>
274 | endbfchar
275 | 1 beginbfchar
276 | <04> <0069>
277 | endbfchar
278 | 1 beginbfchar
279 | <05> <0077>
280 | endbfchar
281 | 1 beginbfchar
282 | <06> <0053>
283 | endbfchar
284 | 1 beginbfchar
285 | <07> <0073>
286 | endbfchar
287 | 1 beginbfchar
288 | <08> <006E>
289 | endbfchar
290 | 1 beginbfchar
291 | <09> <0041>
292 | endbfchar
293 | 1 beginbfchar
294 | <0A> <0032>
295 | endbfchar
296 | 1 beginbfchar
297 | <0B> <0030>
298 | endbfchar
299 | 1 beginbfchar
300 | <0C> <0072>
301 | endbfchar
302 | 1 beginbfchar
303 | <0D> <0020>
304 | endbfchar
305 | 1 beginbfchar
306 | <0E> <006E>
307 | endbfchar
308 | 1 beginbfchar
309 | <0F> <0061>
310 | endbfchar
311 | 1 beginbfchar
312 | <10> <006D>
313 | endbfchar
314 | 1 beginbfchar
315 | <11> <0069>
316 | endbfchar
317 | 1 beginbfchar
318 | <12> <0065>
319 | endbfchar
320 | 1 beginbfchar
321 | <13> <0053>
322 | endbfchar
323 | endcmap
324 | CMapName currentdict /CMap defineresource pop
325 | end
326 | end
327 | endstream
328 | endobj
329 |
330 | 43 0 obj
331 | 1016
332 | endobj
333 |
334 | 44 0 obj
335 | << /Subtype /Type3
336 | /CharProcs << /C9 5 0 R
337 | /C3 1 0 R
338 | /C18 3 0 R
339 | /C2 7 0 R
340 | /C4 9 0 R
341 | /C7 15 0 R
342 | /C0 11 0 R
343 | /C16 13 0 R
344 | /C5 17 0 R
345 | /C6 19 0 R
346 | /C15 21 0 R
347 | /C8 23 0 R
348 | /C1 25 0 R
349 | /C10 27 0 R
350 | /C11 29 0 R
351 | /C12 31 0 R
352 | /C13 33 0 R
353 | /C14 35 0 R
354 | /C17 37 0 R
355 | /C19 39 0 R
356 | >>
357 | /Encoding << /Type /Encoding
358 | /Differences [ 0 /C0 /C1 /C2 /C3 /C4 /C5 /C6 /C7 /C8 /C9 /C10 /C11 /C12 /C13 /C14 /C15 /C16 /C17 /C18 /C19 ]
359 | >>
360 | /Widths 41 0 R
361 | /FontBBox [ 0.000000 0.000000 0.000000 0.000000 ]
362 | /FontMatrix [ 1.000000 0.000000 0.000000 1.000000 0.000000 0.000000 ]
363 | /Type /Font
364 | /ToUnicode 42 0 R
365 | /FirstChar 0
366 | /LastChar 19
367 | /Resources << >>
368 | >>
369 | endobj
370 |
371 | 45 0 obj
372 | << /Font << /F1 44 0 R >> >>
373 | endobj
374 |
375 | 46 0 obj
376 | << /Length 47 0 R >>
377 | stream
378 | /DeviceRGB CS
379 | /DeviceRGB cs
380 | q
381 | 1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
382 | 1.000000 1.000000 1.000000 scn
383 | 0.000000 500.000000 m
384 | 0.000000 516.568542 13.431458 530.000000 30.000000 530.000000 c
385 | 288.000000 530.000000 l
386 | 304.568542 530.000000 318.000000 516.568542 318.000000 500.000000 c
387 | 318.000000 30.000000 l
388 | 318.000000 13.431458 304.568542 0.000000 288.000000 0.000000 c
389 | 29.999996 0.000000 l
390 | 13.431454 0.000000 0.000000 13.431458 0.000000 30.000000 c
391 | 0.000000 500.000000 l
392 | h
393 | f
394 | n
395 | Q
396 | q
397 | 1.000000 0.000000 -0.000000 1.000000 84.000000 0.000000 cm
398 | 1.000000 0.266667 0.149020 scn
399 | 0.000000 32.179817 m
400 | 0.000000 53.718853 17.460831 71.179688 38.999866 71.179688 c
401 | 38.999866 71.179688 l
402 | 60.538902 71.179688 77.999733 53.718853 77.999733 32.179817 c
403 | 77.999733 0.000015 l
404 | 0.000000 0.000015 l
405 | 0.000000 32.179817 l
406 | h
407 | f
408 | n
409 | Q
410 | q
411 | 1.000000 0.000000 -0.000000 1.000000 162.000000 0.000000 cm
412 | 1.000000 0.266667 0.149020 scn
413 | 0.000000 77.388809 m
414 | 0.000000 98.927849 17.460831 116.388672 38.999866 116.388672 c
415 | 38.999866 116.388672 l
416 | 60.538902 116.388672 77.999733 98.927834 77.999733 77.388794 c
417 | 77.999733 0.000290 l
418 | 0.000000 0.000290 l
419 | 0.000000 77.388809 l
420 | h
421 | f
422 | n
423 | Q
424 | q
425 | 1.000000 0.000000 -0.000000 1.000000 240.000000 0.000000 cm
426 | 1.000000 0.266667 0.149020 scn
427 | 0.000000 122.597664 m
428 | 0.000000 144.136765 17.460896 161.597656 39.000000 161.597656 c
429 | 39.000000 161.597656 l
430 | 60.539104 161.597656 78.000000 144.136765 78.000000 122.597656 c
431 | 78.000000 30.000549 l
432 | 78.000000 13.432007 64.568542 0.000565 48.000000 0.000565 c
433 | 0.000000 0.000565 l
434 | 0.000000 122.597664 l
435 | h
436 | f
437 | n
438 | Q
439 | q
440 | 1.000000 0.000000 -0.000000 1.000000 18.000000 289.060547 cm
441 | 0.000000 0.000000 0.000000 scn
442 | 0.000000 -1.060547 m
443 | h
444 | 0.263672 -1.060547 m
445 | 2.900391 -1.060547 l
446 | 4.091797 2.582031 l
447 | 9.335938 2.582031 l
448 | 10.527344 -1.060547 l
449 | 13.173828 -1.060547 l
450 | 8.134766 13.031250 l
451 | 5.302734 13.031250 l
452 | 0.263672 -1.060547 l
453 | h
454 | 6.689453 10.521484 m
455 | 6.748047 10.521484 l
456 | 8.701172 4.525391 l
457 | 4.726562 4.525391 l
458 | 6.689453 10.521484 l
459 | h
460 | 13.758906 -1.060547 m
461 | h
462 | 18.797970 -1.265625 m
463 | 21.337032 -1.265625 23.143673 0.042969 23.143673 1.996094 c
464 | 23.143673 2.005859 l
465 | 23.143673 3.509766 22.313595 4.349609 20.174923 4.828125 c
466 | 18.446407 5.208984 l
467 | 17.372189 5.453125 16.971798 5.843750 16.971798 6.439453 c
468 | 16.971798 6.449219 l
469 | 16.971798 7.210938 17.645626 7.708984 18.719845 7.708984 c
470 | 19.852657 7.708984 20.516720 7.103516 20.624142 6.273438 c
471 | 20.633907 6.195312 l
472 | 22.899532 6.195312 l
473 | 22.889767 6.302734 l
474 | 22.801876 8.089844 21.297970 9.505859 18.719845 9.505859 c
475 | 16.219845 9.505859 14.569453 8.226562 14.569453 6.332031 c
476 | 14.569453 6.322266 l
477 | 14.569453 4.789062 15.565547 3.822266 17.547970 3.382812 c
478 | 19.266720 3.001953 l
479 | 20.340939 2.757812 20.702267 2.406250 20.702267 1.781250 c
480 | 20.702267 1.771484 l
481 | 20.702267 1.009766 19.989376 0.531250 18.807735 0.531250 c
482 | 17.577267 0.531250 16.903439 1.058594 16.717892 1.947266 c
483 | 16.698360 2.035156 l
484 | 14.305781 2.035156 l
485 | 14.315547 1.947266 l
486 | 14.530391 0.023438 16.083126 -1.265625 18.797970 -1.265625 c
487 | h
488 | 24.041250 -1.060547 m
489 | h
490 | 26.424063 -4.498047 m
491 | 28.582266 -4.498047 29.793203 -3.687500 30.584219 -1.412109 c
492 | 34.304924 9.291016 l
493 | 31.746328 9.291016 l
494 | 29.314688 1.009766 l
495 | 29.246328 1.009766 l
496 | 26.824453 9.291016 l
497 | 24.168203 9.291016 l
498 | 27.888906 -1.070312 l
499 | 27.742422 -1.490234 l
500 | 27.429922 -2.359375 26.912344 -2.623047 25.984610 -2.623047 c
501 | 25.642813 -2.623047 25.359610 -2.574219 25.174063 -2.535156 c
502 | 25.174063 -4.390625 l
503 | 25.467031 -4.439453 25.955313 -4.498047 26.424063 -4.498047 c
504 | h
505 | 34.811874 -1.060547 m
506 | h
507 | 35.807968 -1.060547 m
508 | 38.239609 -1.060547 l
509 | 38.239609 4.945312 l
510 | 38.239609 6.439453 39.128281 7.455078 40.524765 7.455078 c
511 | 41.911484 7.455078 42.595078 6.625000 42.595078 5.150391 c
512 | 42.595078 -1.060547 l
513 | 45.026718 -1.060547 l
514 | 45.026718 5.638672 l
515 | 45.026718 8.041016 43.727890 9.505859 41.452499 9.505859 c
516 | 39.880234 9.505859 38.825546 8.783203 38.288437 7.669922 c
517 | 38.239609 7.669922 l
518 | 38.239609 9.291016 l
519 | 35.807968 9.291016 l
520 | 35.807968 -1.060547 l
521 | h
522 | 46.363750 -1.060547 m
523 | h
524 | 51.930157 -1.265625 m
525 | 54.547344 -1.265625 56.295391 0.375000 56.480938 2.533203 c
526 | 56.490704 2.601562 l
527 | 54.195782 2.601562 l
528 | 54.176250 2.513672 l
529 | 53.951641 1.429688 53.170391 0.697266 51.939922 0.697266 c
530 | 50.406719 0.697266 49.410625 1.947266 49.410625 4.105469 c
531 | 49.410625 4.115234 l
532 | 49.410625 6.234375 50.396954 7.533203 51.930157 7.533203 c
533 | 53.219219 7.533203 53.961407 6.722656 54.166485 5.716797 c
534 | 54.186016 5.628906 l
535 | 56.480938 5.628906 l
536 | 56.471172 5.707031 l
537 | 56.324688 7.777344 54.625469 9.505859 51.900860 9.505859 c
538 | 48.883282 9.505859 46.930157 7.435547 46.930157 4.134766 c
539 | 46.930157 4.125000 l
540 | 46.930157 0.804688 48.853985 -1.265625 51.930157 -1.265625 c
541 | h
542 | 57.329689 -1.060547 m
543 | h
544 | 63.521095 -1.304688 m
545 | 66.958595 -1.304688 69.028908 0.365234 69.028908 2.992188 c
546 | 69.028908 3.001953 l
547 | 69.028908 5.199219 67.749611 6.390625 64.819923 6.996094 c
548 | 63.296486 7.308594 l
549 | 61.597267 7.660156 60.825783 8.246094 60.825783 9.242188 c
550 | 60.825783 9.251953 l
551 | 60.825783 10.375000 61.851173 11.146484 63.501564 11.156250 c
552 | 65.083595 11.156250 66.167580 10.423828 66.333595 9.193359 c
553 | 66.353127 9.076172 l
554 | 68.765236 9.076172 l
555 | 68.755470 9.242188 l
556 | 68.608986 11.654297 66.597267 13.275391 63.521095 13.275391 c
557 | 60.483986 13.275391 58.306252 11.595703 58.296486 9.115234 c
558 | 58.296486 9.105469 l
559 | 58.296486 7.005859 59.663673 5.716797 62.437111 5.140625 c
560 | 63.950783 4.828125 l
561 | 65.767189 4.447266 66.499611 3.880859 66.499611 2.826172 c
562 | 66.499611 2.816406 l
563 | 66.499611 1.605469 65.386330 0.814453 63.608986 0.814453 c
564 | 61.841408 0.814453 60.601173 1.566406 60.415627 2.777344 c
565 | 60.396095 2.894531 l
566 | 57.983986 2.894531 l
567 | 57.993752 2.748047 l
568 | 58.159767 0.218750 60.288673 -1.304688 63.521095 -1.304688 c
569 | h
570 | 70.053436 -1.060547 m
571 | h
572 | 73.071014 -1.060547 m
573 | 75.610077 -1.060547 l
574 | 77.641327 6.507812 l
575 | 77.690155 6.507812 l
576 | 79.731171 -1.060547 l
577 | 82.289764 -1.060547 l
578 | 85.092499 9.291016 l
579 | 82.690155 9.291016 l
580 | 80.942108 1.341797 l
581 | 80.883514 1.341797 l
582 | 78.862030 9.291016 l
583 | 76.528046 9.291016 l
584 | 74.506561 1.341797 l
585 | 74.457733 1.341797 l
586 | 72.709686 9.291016 l
587 | 70.268280 9.291016 l
588 | 73.071014 -1.060547 l
589 | h
590 | 85.687347 -1.060547 m
591 | h
592 | 87.972504 10.775391 m
593 | 88.734222 10.775391 89.320160 11.380859 89.320160 12.103516 c
594 | 89.320160 12.845703 88.734222 13.441406 87.972504 13.441406 c
595 | 87.210785 13.441406 86.615082 12.845703 86.615082 12.103516 c
596 | 86.615082 11.380859 87.210785 10.775391 87.972504 10.775391 c
597 | h
598 | 86.751801 -1.060547 m
599 | 89.183441 -1.060547 l
600 | 89.183441 9.291016 l
601 | 86.751801 9.291016 l
602 | 86.751801 -1.060547 l
603 | h
604 | 90.637657 -1.060547 m
605 | h
606 | 92.307579 -1.060547 m
607 | 94.739220 -1.060547 l
608 | 94.739220 7.416016 l
609 | 96.770470 7.416016 l
610 | 96.770470 9.291016 l
611 | 94.690392 9.291016 l
612 | 94.690392 10.208984 l
613 | 94.690392 11.048828 95.129845 11.488281 96.077110 11.488281 c
614 | 96.340782 11.488281 96.614220 11.468750 96.809532 11.439453 c
615 | 96.809532 13.138672 l
616 | 96.506798 13.197266 95.989220 13.236328 95.510704 13.236328 c
617 | 93.264610 13.236328 92.307579 12.298828 92.307579 10.277344 c
618 | 92.307579 9.291016 l
619 | 90.891563 9.291016 l
620 | 90.891563 7.416016 l
621 | 92.307579 7.416016 l
622 | 92.307579 -1.060547 l
623 | h
624 | 97.755936 -1.060547 m
625 | h
626 | 102.599686 -1.265625 m
627 | 103.087967 -1.265625 103.517654 -1.216797 103.800858 -1.177734 c
628 | 103.800858 0.648438 l
629 | 103.634842 0.638672 103.449295 0.609375 103.214920 0.609375 c
630 | 102.326248 0.609375 101.877029 0.931641 101.877029 1.947266 c
631 | 101.877029 7.416016 l
632 | 103.800858 7.416016 l
633 | 103.800858 9.291016 l
634 | 101.877029 9.291016 l
635 | 101.877029 11.917969 l
636 | 99.406326 11.917969 l
637 | 99.406326 9.291016 l
638 | 97.941483 9.291016 l
639 | 97.941483 7.416016 l
640 | 99.406326 7.416016 l
641 | 99.406326 1.742188 l
642 | 99.406326 -0.386719 100.431717 -1.265625 102.599686 -1.265625 c
643 | h
644 | f
645 | n
646 | Q
647 | q
648 | 1.000000 0.000000 -0.000000 1.000000 18.000000 289.060547 cm
649 | BT
650 | 20.000000 0.000000 0.000000 20.000000 0.000000 -1.060547 Tm
651 | /F1 1.000000 Tf
652 | [ (\t) -16.558599 (\007) -17.535162 (\003) -18.999958 (\010) -19.488335 (\002) -19.488335 (\006) -18.999863 (\005) -19.000244 (\004) -19.488144 (\001) -18.999863 (\000) ] TJ
653 | ET
654 | Q
655 | q
656 | 1.000000 0.000000 -0.000000 1.000000 18.000000 258.439453 cm
657 | 0.000000 0.000000 0.000000 scn
658 | 0.000000 -7.439453 m
659 | h
660 | 13.480000 11.920547 m
661 | 12.440001 13.387215 11.120000 14.120548 9.520000 14.120548 c
662 | 8.506667 14.120548 7.653334 13.813882 6.960001 13.200548 c
663 | 6.266667 12.587214 5.920000 11.760548 5.920000 10.720548 c
664 | 5.920000 9.707213 7.173333 8.493879 9.680000 7.080547 c
665 | 12.213334 5.667213 13.493334 4.960546 13.520001 4.960546 c
666 | 15.306667 3.627214 16.200001 2.053881 16.200001 0.240547 c
667 | 16.200001 -2.239452 15.480000 -4.252787 14.040001 -5.799454 c
668 | 12.626667 -7.319454 10.680000 -8.079453 8.200001 -8.079453 c
669 | 5.053334 -8.079453 2.573334 -6.506119 0.760000 -3.359453 c
670 | 3.360000 -0.799454 l
671 | 3.706667 -1.732786 4.346667 -2.586121 5.280000 -3.359453 c
672 | 6.213334 -4.132786 7.173334 -4.519453 8.160001 -4.519453 c
673 | 9.333334 -4.519453 10.253333 -4.119453 10.920000 -3.319452 c
674 | 11.613334 -2.519453 11.960001 -1.546120 11.960001 -0.399452 c
675 | 11.960001 0.427214 11.706667 1.147215 11.200000 1.760548 c
676 | 10.693334 2.400547 8.973333 3.360546 6.040000 4.640547 c
677 | 3.133333 5.947214 1.680000 7.800549 1.680000 10.200547 c
678 | 1.680000 12.253881 2.346667 13.973881 3.680000 15.360549 c
679 | 5.040000 16.773882 6.733334 17.480549 8.760000 17.480549 c
680 | 11.560000 17.480549 13.773334 16.573881 15.400001 14.760548 c
681 | 13.480000 11.920547 l
682 | h
683 | 16.914062 -7.439453 m
684 | h
685 | 21.514063 0.080547 m
686 | 21.514063 -1.439453 21.847397 -2.692787 22.514063 -3.679453 c
687 | 23.180729 -4.666119 24.100729 -5.159452 25.274063 -5.159452 c
688 | 27.114063 -5.159452 28.287397 -4.252787 28.794064 -2.439453 c
689 | 32.474064 -3.039454 l
690 | 31.274063 -6.319454 28.874063 -7.959454 25.274063 -7.959454 c
691 | 22.874063 -7.959454 20.954063 -7.199455 19.514063 -5.679453 c
692 | 18.100729 -4.132786 17.394062 -2.132786 17.394062 0.320547 c
693 | 17.394062 2.907213 18.074062 4.973881 19.434063 6.520548 c
694 | 20.820730 8.093882 22.687397 8.880548 25.034063 8.880548 c
695 | 27.407396 8.880548 29.247396 8.133883 30.554064 6.640549 c
696 | 31.887396 5.173882 32.554062 3.160547 32.554062 0.600548 c
697 | 32.554062 0.080547 l
698 | 21.514063 0.080547 l
699 | h
700 | 28.954063 2.240547 m
701 | 28.954063 3.333881 28.607397 4.293882 27.914062 5.120548 c
702 | 27.247396 5.947214 26.367395 6.360548 25.274063 6.360548 c
703 | 24.207396 6.360548 23.327396 5.920547 22.634064 5.040546 c
704 | 21.967398 4.187214 21.634064 3.253881 21.634064 2.240547 c
705 | 28.954063 2.240547 l
706 | h
707 | 33.046875 -7.439453 m
708 | h
709 | 38.526875 6.960548 m
710 | 39.513542 8.240549 41.033543 8.880548 43.086876 8.880548 c
711 | 44.873543 8.880548 46.286877 8.053881 47.326878 6.400547 c
712 | 48.340210 8.053881 49.980209 8.880548 52.246876 8.880548 c
713 | 55.580208 8.880548 57.246876 6.720549 57.246876 2.400547 c
714 | 57.246876 -7.439453 l
715 | 53.406876 -7.439453 l
716 | 53.406876 0.880547 l
717 | 53.406876 4.240547 52.486877 5.920547 50.646877 5.920547 c
718 | 48.806877 5.920547 47.886875 4.053881 47.886875 0.320547 c
719 | 47.886875 -7.439453 l
720 | 44.006874 -7.439453 l
721 | 44.006874 0.560547 l
722 | 44.006874 4.133881 43.193542 5.920547 41.566875 5.920547 c
723 | 39.540207 5.920547 38.526875 4.053881 38.526875 0.320547 c
724 | 38.526875 -7.439453 l
725 | 34.646873 -7.439453 l
726 | 34.646873 8.360548 l
727 | 38.526875 8.360548 l
728 | 38.526875 6.960548 l
729 | h
730 | 58.906250 -7.439453 m
731 | h
732 | 65.266251 15.000549 m
733 | 65.266251 14.387216 65.039581 13.853882 64.586250 13.400548 c
734 | 64.132919 12.947214 63.599583 12.720547 62.986252 12.720547 c
735 | 62.372917 12.720547 61.839584 12.947214 61.386250 13.400548 c
736 | 60.932915 13.853882 60.706249 14.387216 60.706249 15.000549 c
737 | 60.706249 15.613883 60.932915 16.147217 61.386250 16.600548 c
738 | 61.839584 17.053883 62.372917 17.280548 62.986252 17.280548 c
739 | 63.599583 17.280548 64.132919 17.053883 64.586250 16.600548 c
740 | 65.039581 16.147217 65.266251 15.613883 65.266251 15.000549 c
741 | h
742 | 64.946251 -7.439453 m
743 | 61.066250 -7.439453 l
744 | 61.066250 8.360548 l
745 | 64.946251 8.360548 l
746 | 64.946251 -7.439453 l
747 | h
748 | 67.070312 -7.439453 m
749 | h
750 | 72.550316 6.920547 m
751 | 73.643646 8.227215 75.256981 8.880548 77.390312 8.880548 c
752 | 81.016983 8.880548 82.830315 6.653881 82.830315 2.200548 c
753 | 82.830315 -7.439453 l
754 | 78.990311 -7.439453 l
755 | 78.990311 0.920547 l
756 | 78.990311 4.253881 77.963646 5.920547 75.910316 5.920547 c
757 | 73.670319 5.920547 72.550316 4.133881 72.550316 0.560547 c
758 | 72.550316 -7.439453 l
759 | 68.670311 -7.439453 l
760 | 68.670311 8.360548 l
761 | 72.550316 8.360548 l
762 | 72.550316 6.920547 l
763 | h
764 | 84.453125 -7.439453 m
765 | h
766 | 101.013123 -7.439453 m
767 | 97.213127 -7.439453 l
768 | 97.213127 -5.999453 l
769 | 96.093124 -7.306122 94.426460 -7.959454 92.213127 -7.959454 c
770 | 89.999794 -7.959454 88.226463 -7.106121 86.893127 -5.399452 c
771 | 85.586464 -3.692785 84.933128 -1.692785 84.933128 0.600548 c
772 | 84.933128 2.893881 85.599792 4.840548 86.933128 6.440548 c
773 | 88.293121 8.067215 90.026459 8.880548 92.133125 8.880548 c
774 | 94.373123 8.880548 96.066460 8.213881 97.213127 6.880547 c
775 | 97.213127 8.360548 l
776 | 101.013123 8.360548 l
777 | 101.013123 -7.439453 l
778 | h
779 | 97.653122 0.480547 m
780 | 97.653122 1.947214 97.239792 3.147215 96.413124 4.080547 c
781 | 95.586456 5.040546 94.519791 5.520546 93.213127 5.520546 c
782 | 91.933121 5.520546 90.879791 5.053879 90.053123 4.120546 c
783 | 89.253128 3.213881 88.853127 2.000547 88.853127 0.480547 c
784 | 88.853127 -1.039454 89.266457 -2.266121 90.093124 -3.199453 c
785 | 90.919792 -4.132786 91.986458 -4.599453 93.293129 -4.599453 c
786 | 94.599792 -4.599453 95.653130 -4.146120 96.453125 -3.239452 c
787 | 97.253120 -2.332785 97.653122 -1.092785 97.653122 0.480547 c
788 | h
789 | 102.656250 -7.439453 m
790 | h
791 | 108.136253 6.720547 m
792 | 109.122917 8.160547 110.669586 8.880548 112.776253 8.880548 c
793 | 113.442917 8.880548 114.056252 8.747215 114.616249 8.480549 c
794 | 114.136253 4.960546 l
795 | 113.549583 5.333879 112.909584 5.520546 112.216248 5.520546 c
796 | 109.496254 5.520546 108.136253 3.573879 108.136253 -0.319452 c
797 | 108.136253 -7.439453 l
798 | 104.256248 -7.439453 l
799 | 104.256248 8.360548 l
800 | 108.136253 8.360548 l
801 | 108.136253 6.720547 l
802 | h
803 | 127.812500 -7.439453 m
804 | h
805 | 145.812500 4.680548 m
806 | 145.812500 1.160547 145.039169 -1.852787 143.492508 -4.359453 c
807 | 141.945831 -6.839455 139.812500 -8.079453 137.092499 -8.079453 c
808 | 134.399170 -8.079453 132.279175 -6.826118 130.732498 -4.319452 c
809 | 129.185837 -1.812786 128.412506 1.187214 128.412506 4.680548 c
810 | 128.412506 8.307215 129.185837 11.347214 130.732498 13.800548 c
811 | 132.305832 16.253881 134.439163 17.480549 137.132507 17.480549 c
812 | 139.825836 17.480549 141.945831 16.227215 143.492508 13.720549 c
813 | 145.039169 11.240547 145.812500 8.227215 145.812500 4.680548 c
814 | h
815 | 132.412506 4.720547 m
816 | 132.412506 2.267214 132.839172 0.093880 133.692505 -1.799454 c
817 | 134.545837 -3.666121 135.705841 -4.599453 137.172501 -4.599453 c
818 | 138.639160 -4.599453 139.772491 -3.719454 140.572495 -1.959454 c
819 | 141.399170 -0.199453 141.812500 2.027214 141.812500 4.720547 c
820 | 141.812500 7.200548 141.385834 9.360548 140.532501 11.200548 c
821 | 139.679169 13.067214 138.532501 14.000547 137.092499 14.000547 c
822 | 135.679169 14.000547 134.545837 13.027214 133.692505 11.080548 c
823 | 132.839172 9.160549 132.412506 7.040548 132.412506 4.720547 c
824 | h
825 | 146.406250 -7.439453 m
826 | h
827 | 164.406250 4.680548 m
828 | 164.406250 1.160547 163.632919 -1.852787 162.086258 -4.359453 c
829 | 160.539581 -6.839455 158.406250 -8.079453 155.686249 -8.079453 c
830 | 152.992920 -8.079453 150.872925 -6.826118 149.326248 -4.319452 c
831 | 147.779587 -1.812786 147.006256 1.187214 147.006256 4.680548 c
832 | 147.006256 8.307215 147.779587 11.347214 149.326248 13.800548 c
833 | 150.899582 16.253881 153.032913 17.480549 155.726257 17.480549 c
834 | 158.419586 17.480549 160.539581 16.227215 162.086258 13.720549 c
835 | 163.632919 11.240547 164.406250 8.227215 164.406250 4.680548 c
836 | h
837 | 151.006256 4.720547 m
838 | 151.006256 2.267214 151.432922 0.093880 152.286255 -1.799454 c
839 | 153.139587 -3.666121 154.299591 -4.599453 155.766251 -4.599453 c
840 | 157.232910 -4.599453 158.366241 -3.719454 159.166245 -1.959454 c
841 | 159.992920 -0.199453 160.406250 2.027214 160.406250 4.720547 c
842 | 160.406250 7.200548 159.979584 9.360548 159.126251 11.200548 c
843 | 158.272919 13.067214 157.126251 14.000547 155.686249 14.000547 c
844 | 154.272919 14.000547 153.139587 13.027214 152.286255 11.080548 c
845 | 151.432922 9.160549 151.006256 7.040548 151.006256 4.720547 c
846 | h
847 | 165.000000 -7.439453 m
848 | h
849 | 182.240005 -3.959454 m
850 | 182.240005 -7.439453 l
851 | 165.919998 -7.439453 l
852 | 172.080002 0.360548 l
853 | 175.013336 3.987215 176.733337 6.253881 177.240005 7.160547 c
854 | 177.773331 8.093880 178.039993 8.853880 178.039993 9.440548 c
855 | 178.066666 9.600548 178.080002 9.760548 178.080002 9.920547 c
856 | 178.080002 10.987214 177.720001 11.933881 177.000000 12.760547 c
857 | 176.306671 13.587214 175.386673 14.000547 174.240005 14.000547 c
858 | 173.093338 14.000547 172.186676 13.533881 171.520004 12.600549 c
859 | 170.880005 11.667215 170.559998 10.533881 170.559998 9.200546 c
860 | 166.240005 9.200546 l
861 | 166.240005 11.600548 166.946671 13.573881 168.360001 15.120547 c
862 | 169.800003 16.693882 171.746674 17.480549 174.199997 17.480549 c
863 | 176.413330 17.480549 178.279999 16.733881 179.800003 15.240548 c
864 | 181.320007 13.747215 182.080002 11.880548 182.080002 9.640548 c
865 | 182.080002 7.533880 180.466675 4.600548 177.240005 0.840548 c
866 | 173.119995 -3.959454 l
867 | 182.240005 -3.959454 l
868 | h
869 | f
870 | n
871 | Q
872 | q
873 | 1.000000 0.000000 -0.000000 1.000000 18.000000 258.439453 cm
874 | BT
875 | 40.000000 0.000000 0.000000 40.000000 0.000000 -7.439453 Tm
876 | /F1 1.000000 Tf
877 | [ (\023) (\022) (\020) (\021) (\016) (\017) (\014) (\015) (\013) (\013) (\n) ] TJ
878 | ET
879 | Q
880 |
881 | endstream
882 | endobj
883 |
884 | 47 0 obj
885 | 16907
886 | endobj
887 |
888 | 48 0 obj
889 | << /Annots []
890 | /Type /Page
891 | /MediaBox [ 0.000000 0.000000 318.000000 530.000000 ]
892 | /Resources 45 0 R
893 | /Contents 46 0 R
894 | /Parent 49 0 R
895 | >>
896 | endobj
897 |
898 | 49 0 obj
899 | << /Kids [ 48 0 R ]
900 | /Count 1
901 | /Type /Pages
902 | >>
903 | endobj
904 |
905 | 50 0 obj
906 | << /Pages 49 0 R
907 | /Type /Catalog
908 | >>
909 | endobj
910 |
911 | xref
912 | 0 51
913 | 0000000000 65535 f
914 | 0000000010 00000 n
915 | 0000000117 00000 n
916 | 0000000138 00000 n
917 | 0000000245 00000 n
918 | 0000000266 00000 n
919 | 0000000372 00000 n
920 | 0000000393 00000 n
921 | 0000000500 00000 n
922 | 0000000521 00000 n
923 | 0000000628 00000 n
924 | 0000000650 00000 n
925 | 0000000759 00000 n
926 | 0000000781 00000 n
927 | 0000000889 00000 n
928 | 0000000911 00000 n
929 | 0000001020 00000 n
930 | 0000001042 00000 n
931 | 0000001150 00000 n
932 | 0000001172 00000 n
933 | 0000001281 00000 n
934 | 0000001303 00000 n
935 | 0000001412 00000 n
936 | 0000001434 00000 n
937 | 0000001542 00000 n
938 | 0000001564 00000 n
939 | 0000001672 00000 n
940 | 0000001694 00000 n
941 | 0000001802 00000 n
942 | 0000001824 00000 n
943 | 0000001933 00000 n
944 | 0000001955 00000 n
945 | 0000002063 00000 n
946 | 0000002085 00000 n
947 | 0000002193 00000 n
948 | 0000002215 00000 n
949 | 0000002323 00000 n
950 | 0000002345 00000 n
951 | 0000002453 00000 n
952 | 0000002475 00000 n
953 | 0000002584 00000 n
954 | 0000002606 00000 n
955 | 0000002809 00000 n
956 | 0000003883 00000 n
957 | 0000003907 00000 n
958 | 0000005001 00000 n
959 | 0000005049 00000 n
960 | 0000022014 00000 n
961 | 0000022039 00000 n
962 | 0000022218 00000 n
963 | 0000022294 00000 n
964 | trailer
965 | << /ID [ (some) (id) ]
966 | /Root 50 0 R
967 | /Size 51
968 | >>
969 | startxref
970 | 22355
971 | %%EOF
--------------------------------------------------------------------------------