diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Core.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Core.swift index 96b272f..8a7bcb8 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Core.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Core.swift @@ -16,4 +16,5 @@ public enum Core: String, Modulable { case UserDefaultsClient case FeedbackGeneratorClient case NetworkTracking + case StreamListener } diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Feature.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Feature.swift index a7c2930..7e06012 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Feature.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Feature.swift @@ -15,4 +15,5 @@ public enum Feature: String, Modulable { case MyPageFeature case PomodoroFeature case CatFeature + case ErrorFeature } diff --git a/Projects/Core/APIClient/Interface/Model/NetworkError.swift b/Projects/Core/APIClient/Interface/Model/NetworkError.swift index 26fdeac..fb299aa 100644 --- a/Projects/Core/APIClient/Interface/Model/NetworkError.swift +++ b/Projects/Core/APIClient/Interface/Model/NetworkError.swift @@ -8,7 +8,7 @@ import Foundation -public enum NetworkError: Error { +public enum NetworkError: Error, Equatable { case requestError(_ description: String) case apiError(_ description: String) case noResponseError diff --git a/Projects/Core/StreamListener/Interface/StreamListenerInterface.swift b/Projects/Core/StreamListener/Interface/StreamListenerInterface.swift new file mode 100644 index 0000000..b2020b3 --- /dev/null +++ b/Projects/Core/StreamListener/Interface/StreamListenerInterface.swift @@ -0,0 +1,35 @@ +// +// StreamListenerInterface.swift +// StreamListener +// +// Created by jihyun247 on 11/21/24. +// + +import Foundation + +import Dependencies +import DependenciesMacros + +/* + TODO: + StreamListener를 토스트나 다이얼로그, 에러뷰 등 다양한 상황의 스트림을 만들 수 있도록 둘 것인지, serverState 추적만을 하도록 둘 것인지 정해야함 (네이밍 다시 해야함) + ++ NetworkTracking도 그 목적이 StreamListener와 유사함 + */ + +@DependencyClient +public struct StreamListener { + public var sendServerState: @Sendable (_ state: ServerState) async -> Void + public var updateServerState: @Sendable () -> AsyncStream = { .never } +} + +extension StreamListener: TestDependencyKey { + public static let previewValue = Self() + public static let testValue = Self() +} + +public enum ServerState { + case requestStarted + case requestCompleted + case errorOccured + case networkDisabled +} diff --git a/Projects/Core/StreamListener/Project.swift b/Projects/Core/StreamListener/Project.swift new file mode 100644 index 0000000..15f13ce --- /dev/null +++ b/Projects/Core/StreamListener/Project.swift @@ -0,0 +1,22 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +@_spi(Core) +@_spi(Shared) +import DependencyPlugin + +let project: Project = .makeTMABasedProject( + module: Core.StreamListener, + scripts: [], + targets: [ + .sources, + .interface, + .tests, + .testing + ], + dependencies: [ + .interface: [ + .dependency(rootModule: Shared.self) + ] + ] +) diff --git a/Projects/Core/StreamListener/Sources/StreamListener.swift b/Projects/Core/StreamListener/Sources/StreamListener.swift new file mode 100644 index 0000000..4ac9596 --- /dev/null +++ b/Projects/Core/StreamListener/Sources/StreamListener.swift @@ -0,0 +1,46 @@ +// +// StreamListener.swift +// StreamListener +// +// Created by jihyun247 on 11/21/24. +// + +import Foundation + +import StreamListenerInterface + +import Dependencies + +extension StreamListener: DependencyKey { + public static let liveValue: StreamListener = .live() + + public static func live() -> StreamListener { + + // 네이밍 추천 plz ., + actor ContinuationActor { + var continuation: AsyncStream.Continuation? + + func set(_ newContinuation: AsyncStream.Continuation) { + continuation = newContinuation + } + + func yield(_ state: ServerState) { + continuation?.yield(state) + } + } + + let continuationActor = ContinuationActor() + let asyncStream = AsyncStream { continuation in + Task { await continuationActor.set(continuation) } + } + + return StreamListener( + sendServerState: { state in + await continuationActor.yield(state) + }, + updateServerState: { + return asyncStream + } + ) + } +} diff --git a/Projects/Core/StreamListener/Testing/StreamListenerTesting.swift b/Projects/Core/StreamListener/Testing/StreamListenerTesting.swift new file mode 100644 index 0000000..9bc802b --- /dev/null +++ b/Projects/Core/StreamListener/Testing/StreamListenerTesting.swift @@ -0,0 +1,12 @@ +// +// StreamListenerTesting.swift +// StreamListener +// +// Created by <#T##Author name#> on 11/21/24. +// + +import Foundation + +public struct StreamListenerTesting { + public init() {} +} diff --git a/Projects/Core/StreamListener/Tests/StreamListenerTests.swift b/Projects/Core/StreamListener/Tests/StreamListenerTests.swift new file mode 100644 index 0000000..0c0a109 --- /dev/null +++ b/Projects/Core/StreamListener/Tests/StreamListenerTests.swift @@ -0,0 +1,33 @@ +// +// StreamListenerTests.swift +// StreamListener +// +// Created by <#T##Author name#> on 11/21/24. +// + +import XCTest + +final class StreamListenerTests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } +} diff --git a/Projects/Feature/CatFeature/Sources/NamingCat/NamingCatCore.swift b/Projects/Feature/CatFeature/Sources/NamingCat/NamingCatCore.swift index 2bd5ed6..115c57d 100644 --- a/Projects/Feature/CatFeature/Sources/NamingCat/NamingCatCore.swift +++ b/Projects/Feature/CatFeature/Sources/NamingCat/NamingCatCore.swift @@ -12,6 +12,7 @@ import CatServiceInterface import DesignSystem import UserServiceInterface import DatabaseClientInterface +import StreamListenerInterface import ComposableArchitecture import RiveRuntime @@ -41,6 +42,8 @@ public struct NamingCatCore { case setTooltip(DownDirectionTooltip?) case saveChangedCat(SomeCat) case _setNextAction + case _postNamedCatRequest(ChangeCatNameRequest) + case _postNamedCatResponse(Result) case binding(BindingAction) } @@ -54,6 +57,7 @@ public struct NamingCatCore { @Dependency(CatService.self) var catService @Dependency(UserService.self) var userService @Dependency(DatabaseClient.self) var databaseClient + @Dependency(StreamListener.self) var streamListener let isOnboardedKey = "mohanyang_userdefaults_isOnboarded" public init() {} @@ -81,12 +85,7 @@ public struct NamingCatCore { let catName = state.text == "" ? selectedCat.baseInfo.name : state.text let request = ChangeCatNameRequest(name: catName) return .run { send in - try await catService.changeCatName( - apiClient: apiClient, - request: request - ) - try await self.userService.syncUserInfo(apiClient: self.apiClient, databaseClient: self.databaseClient) - await send(._setNextAction) + await send(._postNamedCatRequest(request)) } case .catSetInput: @@ -107,7 +106,7 @@ public struct NamingCatCore { case ._setNextAction: return .run { [state] send in if state.route == .onboarding { - await userDefaultsClient.setBool(true, key: isOnboardedKey) + await self.userDefaultsClient.setBool(true, key: isOnboardedKey) await send(.moveToHome) } else { if let selectedCat = state.selectedCat { @@ -116,6 +115,24 @@ public struct NamingCatCore { } } + case let ._postNamedCatRequest(request): + return .run { send in + await self.streamListener.sendServerState(state: .requestStarted) + await send(._postNamedCatResponse(Result { + try await self.catService.changeCatName(apiClient: apiClient, request: request) + })) + } + + case ._postNamedCatResponse(.success(_)): + return .run { send in + try await self.userService.syncUserInfo(apiClient: self.apiClient, databaseClient: self.databaseClient) + await self.streamListener.sendServerState(state: .requestCompleted) + await send(._setNextAction) + } + + case let ._postNamedCatResponse(.failure(error)): + return self.handleError(error: error) + case .binding(\.text): state.inputFieldError = setError(state.text) if state.text == "" && state.route == .myPage { @@ -148,3 +165,24 @@ public struct NamingCatCore { return error } } + +extension NamingCatCore { + private func handleError(error: any Error) -> EffectOf { + if let networkError = error as? URLError, + networkError.code == .networkConnectionLost || + networkError.code == .notConnectedToInternet { + return .run { send in + await streamListener.sendServerState(state: .networkDisabled) + } + } + guard let error = error as? NetworkError else { return .none } + switch error { + case .apiError(_): + return .run { send in + await streamListener.sendServerState(state: .errorOccured) + } + default: + return .none + } + } +} diff --git a/Projects/Feature/CatFeature/Sources/SelectCat/SelectCatCore.swift b/Projects/Feature/CatFeature/Sources/SelectCat/SelectCatCore.swift index 49650cc..9f3d033 100644 --- a/Projects/Feature/CatFeature/Sources/SelectCat/SelectCatCore.swift +++ b/Projects/Feature/CatFeature/Sources/SelectCat/SelectCatCore.swift @@ -11,6 +11,7 @@ import UserServiceInterface import CatServiceInterface import UserNotificationClientInterface import DatabaseClientInterface +import StreamListenerInterface import DesignSystem import RiveRuntime @@ -41,6 +42,8 @@ public struct SelectCatCore { case _moveToNamingCat case _fetchCatListRequest case _fetchCatListResponse(Result<[Cat], Error>) + case _postSelectedCatRequest(SelectCatRequest) + case _postSelectedCatResponse(Result) case binding(BindingAction) case namingCat(PresentationAction) } @@ -57,6 +60,7 @@ public struct SelectCatCore { @Dependency(CatService.self) var catService @Dependency(UserNotificationClient.self) var userNotificationClient @Dependency(DatabaseClient.self) var databaseClient + @Dependency(StreamListener.self) var streamListener public var body: some ReducerOf { BindingReducer() @@ -95,9 +99,7 @@ public struct SelectCatCore { guard let selectedCat = state.selectedCat else { return .none } let request = SelectCatRequest(catNo: selectedCat.baseInfo.no) return .run { send in - try await userService.selectCat(apiClient: self.apiClient, request: request) - try await userService.syncUserInfo(apiClient: self.apiClient, databaseClient: self.databaseClient) - await send(._setNextAction) + await send(._postSelectedCatRequest(request)) } case .saveChangedCat: @@ -123,21 +125,38 @@ public struct SelectCatCore { case ._fetchCatListRequest: return .run { send in - await send( - ._fetchCatListResponse( - Result { - try await catService.getCatList(apiClient) - } - ) - ) + await streamListener.sendServerState(state: .requestStarted) + await send(._fetchCatListResponse(Result { + try await catService.getCatList(apiClient) + })) } case let ._fetchCatListResponse(.success(response)): state.catList = response.map { SomeCat(baseInfo: $0) } - return .none - - case ._fetchCatListResponse(.failure): - return .none + return .run { send in + await streamListener.sendServerState(state: .requestCompleted) + } + + case let ._fetchCatListResponse(.failure(error)): + return handleError(error: error) + + case let ._postSelectedCatRequest(request): + return .run { send in + await streamListener.sendServerState(state: .requestStarted) + await send(._postSelectedCatResponse(Result { + try await userService.selectCat(apiClient: self.apiClient, request: request) + })) + } + + case ._postSelectedCatResponse(.success(_)): + return .run { send in + try await userService.syncUserInfo(apiClient: self.apiClient, databaseClient: self.databaseClient) + await streamListener.sendServerState(state: .requestCompleted) + await send(._setNextAction) + } + + case let ._postSelectedCatResponse(.failure(error)): + return handleError(error: error) case .binding: return .none @@ -147,3 +166,25 @@ public struct SelectCatCore { } } } + +extension SelectCatCore { + // TODO: 다른 곳에서도 사용될 코드인데 따로 뺄 방법 .. + private func handleError(error: any Error) -> EffectOf { + if let networkError = error as? URLError, + networkError.code == .networkConnectionLost || + networkError.code == .notConnectedToInternet { + return .run { send in + await streamListener.sendServerState(state: .networkDisabled) + } + } + guard let error = error as? NetworkError else { return .none } + switch error { + case .apiError(_): + return .run { send in + await streamListener.sendServerState(state: .errorOccured) + } + default: + return .none + } + } +} diff --git a/Projects/Feature/ErrorFeature/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/Feature/ErrorFeature/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f1cb98f --- /dev/null +++ b/Projects/Feature/ErrorFeature/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "ICON_DEMO.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/ErrorFeature/Example/Resources/Assets.xcassets/contents.json b/Projects/Feature/ErrorFeature/Example/Resources/Assets.xcassets/contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Feature/ErrorFeature/Example/Resources/Assets.xcassets/contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Bold.otf b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Bold.otf new file mode 100644 index 0000000..a52ef39 Binary files /dev/null and b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Bold.otf differ diff --git a/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Medium.otf b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Medium.otf new file mode 100644 index 0000000..a2dc009 Binary files /dev/null and b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Medium.otf differ diff --git a/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Regular.otf b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Regular.otf new file mode 100644 index 0000000..c940185 Binary files /dev/null and b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-Regular.otf differ diff --git a/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-SemiBold.otf b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-SemiBold.otf new file mode 100644 index 0000000..c375b54 Binary files /dev/null and b/Projects/Feature/ErrorFeature/Example/Resources/Font/Pretendard-SemiBold.otf differ diff --git a/Projects/Feature/ErrorFeature/Example/Resources/LaunchScreen.storyboard b/Projects/Feature/ErrorFeature/Example/Resources/LaunchScreen.storyboard new file mode 100644 index 0000000..78a7bf0 --- /dev/null +++ b/Projects/Feature/ErrorFeature/Example/Resources/LaunchScreen.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Feature/ErrorFeature/Example/Sources/ContentView.swift b/Projects/Feature/ErrorFeature/Example/Sources/ContentView.swift new file mode 100644 index 0000000..65d58d6 --- /dev/null +++ b/Projects/Feature/ErrorFeature/Example/Sources/ContentView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +import ErrorFeature + +struct ContentView: View { + @State var isNetworkErrorViewPresented = false + @State var isRequestErrorViewPresented = false + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Button(title: "네트워크 에러 뷰") { + isNetworkErrorViewPresented = true + } + + Button(title: "서버통신 에러 뷰") { + isRequestErrorViewPresented = true + } + + Spacer() + } +// .fullScreenCover(isPresented: $isNetworkErrorViewPresented) { +// NetworkErrorView() +// } +// .fullScreenCover(isPresented: $isRequestErrorViewPresented) { +// RequestErrorView() +// } + } +} + +#Preview { + ContentView() +} diff --git a/Projects/Feature/ErrorFeature/Example/Sources/ErrorFeatureApp.swift b/Projects/Feature/ErrorFeature/Example/Sources/ErrorFeatureApp.swift new file mode 100644 index 0000000..2337940 --- /dev/null +++ b/Projects/Feature/ErrorFeature/Example/Sources/ErrorFeatureApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ErrorFeatureApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Projects/Feature/ErrorFeature/Project.swift b/Projects/Feature/ErrorFeature/Project.swift new file mode 100644 index 0000000..4aff90d --- /dev/null +++ b/Projects/Feature/ErrorFeature/Project.swift @@ -0,0 +1,22 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +@_spi(Feature) +@_spi(Domain) +import DependencyPlugin + +let project: Project = .makeTMABasedProject( + module: Feature.ErrorFeature, + scripts: [], + targets: [ + .sources, + .tests, + .testing, + .example + ], + dependencies: [ + .sources: [ + .dependency(rootModule: Domain.self) + ] + ] +) diff --git a/Projects/Feature/ErrorFeature/Sources/ErrorFeature.swift b/Projects/Feature/ErrorFeature/Sources/ErrorFeature.swift new file mode 100644 index 0000000..8ab3892 --- /dev/null +++ b/Projects/Feature/ErrorFeature/Sources/ErrorFeature.swift @@ -0,0 +1,12 @@ +// +// ErrorFeature.swift +// ErrorFeature +// +// Created by <#T##Author name#> on 11/14/24. +// + +import Foundation + +public struct ErrorFeature { + public init() {} +} diff --git a/Projects/Feature/ErrorFeature/Sources/NetworkError/NetworkErrorCore.swift b/Projects/Feature/ErrorFeature/Sources/NetworkError/NetworkErrorCore.swift new file mode 100644 index 0000000..ea62eac --- /dev/null +++ b/Projects/Feature/ErrorFeature/Sources/NetworkError/NetworkErrorCore.swift @@ -0,0 +1,35 @@ +// +// NetworkErrorCore.swift +// ErrorFeature +// +// Created by 김지현 on 11/24/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import ComposableArchitecture + +@Reducer +public struct NetworkErrorCore { + @ObservableState + public struct State: Equatable { + public init() { } + } + + public enum Action { + case tryAgain + } + + public init() { } + + public var body: some ReducerOf { + Reduce(self.core) + } + + private func core(_ state: inout State, _ action: Action) -> EffectOf { + switch action { + case .tryAgain: + return .none + } + } + +} diff --git a/Projects/Feature/ErrorFeature/Sources/NetworkError/NetworkErrorView.swift b/Projects/Feature/ErrorFeature/Sources/NetworkError/NetworkErrorView.swift new file mode 100644 index 0000000..6b5f66c --- /dev/null +++ b/Projects/Feature/ErrorFeature/Sources/NetworkError/NetworkErrorView.swift @@ -0,0 +1,57 @@ +// +// NetworkErrorView.swift +// ErrorFeature +// +// Created by 김지현 on 11/14/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import SwiftUI + +import DesignSystem + +import ComposableArchitecture + +public struct NetworkErrorView: View { + @Environment(\.dismiss) var dismiss + + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + HStack { + Spacer() + + VStack(spacing: Alias.Spacing.xxxLarge) { + Spacer() + + DesignSystemAsset.Image.noInternet.swiftUIImage + + VStack(spacing: Alias.Spacing.small) { + Text("인터넷 연결이 불안정해요") + .font(Typography.header4) + .foregroundStyle(Alias.Color.Text.primary) + Text("연결 상태를 확인한 후\n다시 시도해 주세요.") + .multilineTextAlignment(.center) + .font(Typography.bodyR) + .foregroundStyle(Alias.Color.Text.secondary) + } + .padding(.bottom, 34) + + Button(title: "다시 시도하기") { + store.send(.tryAgain) + dismiss() + } + .buttonStyle(.box(level: .primary, size: .large, width: .medium)) + + Spacer() + } + + Spacer() + } + .background(Alias.Color.Background.primary) + } +} diff --git a/Projects/Feature/ErrorFeature/Sources/RequestError/RequestErrorCore.swift b/Projects/Feature/ErrorFeature/Sources/RequestError/RequestErrorCore.swift new file mode 100644 index 0000000..9dc5c4c --- /dev/null +++ b/Projects/Feature/ErrorFeature/Sources/RequestError/RequestErrorCore.swift @@ -0,0 +1,42 @@ +// +// RequestErrorCore.swift +// ErrorFeature +// +// Created by 김지현 on 11/24/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import Foundation + +import ComposableArchitecture + +@Reducer +public struct RequestErrorCore { + @ObservableState + public struct State: Equatable { + public init() { } + } + + public enum Action { + case moveToHome + case moveToCustomerService + } + + public init() { } + + @Dependency(\.openURL) var openURL + + public var body: some ReducerOf { + Reduce(self.core) + } + + private func core(_ state: inout State, _ action: Action) -> EffectOf { + switch action { + case .moveToHome: + return .none + case .moveToCustomerService: + guard let feedbackURL = URL(string: "https://forms.gle/wEUPH9Tvxgua4hCZ9") else { return .none } + return .run { _ in await self.openURL(feedbackURL) } + } + } +} diff --git a/Projects/Feature/ErrorFeature/Sources/RequestError/RequestErrorView.swift b/Projects/Feature/ErrorFeature/Sources/RequestError/RequestErrorView.swift new file mode 100644 index 0000000..801fc5b --- /dev/null +++ b/Projects/Feature/ErrorFeature/Sources/RequestError/RequestErrorView.swift @@ -0,0 +1,72 @@ +// +// RequestErrorView.swift +// ErrorFeature +// +// Created by 김지현 on 11/14/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import SwiftUI + +import DesignSystem + +import ComposableArchitecture + +public struct RequestErrorView: View { + @Environment(\.dismiss) var dismiss + + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + HStack { + Spacer() + + VStack(spacing: Alias.Spacing.xxxLarge) { + Spacer() + + DesignSystemAsset.Image.error.swiftUIImage + + VStack(spacing: Alias.Spacing.small) { + Text("문제가 발생했어요") + .font(Typography.header4) + .foregroundStyle(Alias.Color.Text.primary) + Text("잠시 후에 다시 확인해주세요.\n문제가 지속된다면 고객센터에 문의해주세요.") + .multilineTextAlignment(.center) + .font(Typography.bodyR) + .foregroundStyle(Alias.Color.Text.secondary) + } + .padding(.bottom, 34) + + VStack(spacing: Alias.Spacing.large) { + Button(title: "홈으로 이동") { + store.send(.moveToHome) + dismiss() + } + .buttonStyle(.box(level: .primary, size: .large, width: .medium)) + + Button( + action: { + store.send(.moveToCustomerService) + dismiss() + }, + label: { + Text("고객센터 문의") + .font(Typography.bodyR) + .underline(pattern: .solid, color: Alias.Color.Text.tertiary) + .foregroundStyle(Alias.Color.Text.tertiary) + } + ) + } + + Spacer() + } + + Spacer() + } + .background(Alias.Color.Background.primary) + } +} diff --git a/Projects/Feature/ErrorFeature/Testing/ErrorFeatureTesting.swift b/Projects/Feature/ErrorFeature/Testing/ErrorFeatureTesting.swift new file mode 100644 index 0000000..dbda345 --- /dev/null +++ b/Projects/Feature/ErrorFeature/Testing/ErrorFeatureTesting.swift @@ -0,0 +1,12 @@ +// +// ErrorFeatureTesting.swift +// ErrorFeature +// +// Created by <#T##Author name#> on 11/14/24. +// + +import Foundation + +public struct ErrorFeatureTesting { + public init() {} +} diff --git a/Projects/Feature/ErrorFeature/Tests/ErrorFeatureTests.swift b/Projects/Feature/ErrorFeature/Tests/ErrorFeatureTests.swift new file mode 100644 index 0000000..dbe5661 --- /dev/null +++ b/Projects/Feature/ErrorFeature/Tests/ErrorFeatureTests.swift @@ -0,0 +1,33 @@ +// +// ErrorFeatureTests.swift +// ErrorFeature +// +// Created by <#T##Author name#> on 11/14/24. +// + +import XCTest + +final class ErrorFeatureTests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } +} diff --git a/Projects/Feature/Feature/Sources/AppCore.swift b/Projects/Feature/Feature/Sources/AppCore.swift index 3a410fa..a271976 100644 --- a/Projects/Feature/Feature/Sources/AppCore.swift +++ b/Projects/Feature/Feature/Sources/AppCore.swift @@ -11,6 +11,7 @@ import SwiftUI import SplashFeature import HomeFeature import OnboardingFeature +import ErrorFeature import MyPageFeature import PushService import AppService @@ -19,6 +20,7 @@ import UserNotificationClientInterface import CatServiceInterface import UserServiceInterface import DatabaseClientInterface +import StreamListenerInterface import ComposableArchitecture @@ -30,27 +32,37 @@ public struct AppCore { var splash: SplashCore.State? var home: HomeCore.State? var onboarding: OnboardingCore.State? - + @Presents var networkError: NetworkErrorCore.State? + @Presents var requestError: RequestErrorCore.State? + + var isLoading: Bool = false + public init() {} } - public enum Action { + public enum Action: BindableAction { + case binding(BindingAction) case onLoad case appDelegate(AppDelegateCore.Action) case didChangeScenePhase(ScenePhase) case splash(SplashCore.Action) case home(HomeCore.Action) case onboarding(OnboardingCore.Action) + case networkError(PresentationAction) + case requestError(PresentationAction) + case serverState(ServerState) } @Dependency(UserDefaultsClient.self) var userDefaultsClient @Dependency(UserNotificationClient.self) var userNotificationClient @Dependency(UserService.self) var userService @Dependency(DatabaseClient.self) var databaseClient - + @Dependency(StreamListener.self) var streamListener + public init() {} public var body: some ReducerOf { + BindingReducer() Scope(state: \.appDelegate, action: \.appDelegate) { AppDelegateCore() } @@ -64,14 +76,27 @@ public struct AppCore { .ifLet(\.onboarding, action: \.onboarding) { OnboardingCore() } + .ifLet(\.$networkError, action: \.networkError) { + NetworkErrorCore() + } + .ifLet(\.$requestError, action: \.requestError) { + RequestErrorCore() + } } private func core(_ state: inout State, _ action: Action) -> EffectOf { switch action { - case .onLoad: - state.splash = SplashCore.State() + case .binding: return .none + case .onLoad: + state.splash = SplashCore.State() + return .run { send in + for await serverState in streamListener.updateServerState() { + await send(.serverState(serverState)) + } + } + case .appDelegate: return .none @@ -132,6 +157,36 @@ public struct AppCore { case .onboarding: return .none + + case .networkError: + return .none + + // TODO: state 초기화 방법 변경 필요 + 온보딩 첫페이지로 돌아가면 온보딩 carousel 이미지 안뜸 + case .requestError(.presented(.moveToHome)): + if state.onboarding != nil { + state.onboarding = OnboardingCore.State() + } else if state.home != nil { + state.home = HomeCore.State() + } + return .none + + case .requestError: + return .none + + case .serverState(let serverState): + switch serverState { + case .requestStarted: + state.isLoading = true + case .requestCompleted: + state.isLoading = false + case .errorOccured: + state.isLoading = false + state.requestError = RequestErrorCore.State() + case .networkDisabled: + state.isLoading = false + state.networkError = NetworkErrorCore.State() + } + return .none } } } diff --git a/Projects/Feature/Feature/Sources/AppView.swift b/Projects/Feature/Feature/Sources/AppView.swift index 93ac061..5d3cf25 100644 --- a/Projects/Feature/Feature/Sources/AppView.swift +++ b/Projects/Feature/Feature/Sources/AppView.swift @@ -12,12 +12,14 @@ import DesignSystem import SplashFeature import HomeFeature import OnboardingFeature +import ErrorFeature import ComposableArchitecture +import Lottie public struct AppView: View { - let store: StoreOf - + @Bindable var store: StoreOf + public init(store: StoreOf) { self.store = store } @@ -47,5 +49,40 @@ public struct AppView: View { .onLoad { store.send(.onLoad) } + .fullScreenCover(isPresented: $store.isLoading) { + VStack { + Spacer() + ZStack { + RoundedRectangle(cornerRadius: Alias.BorderRadius.medium) + .foregroundStyle(Alias.Color.Background.inverse) + .opacity(Global.Opacity._90d) + LottieView(animation: AnimationAsset.lotiSpinner.animation) + .playing(loopMode: .loop) + } + .frame(width: 82, height: 82) + Spacer() + } + .presentationBackground(.clear) + } + .fullScreenCover( + item: $store.scope( + state: \.requestError, + action: \.requestError + ) + ) { store in + RequestErrorView(store: store) + } + .fullScreenCover( + item: $store.scope( + state: \.networkError, + action: \.networkError + ) + ) { store in + NetworkErrorView(store: store) + } + .transaction(value: store.isLoading) { transaction in + // TODO: 11/24, LoadingView 분리 + fullscreen animation disable + transaction.disablesAnimations = true + } } } diff --git a/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/error.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/error.imageset/Contents.json new file mode 100644 index 0000000..8d95c25 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/error.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "error.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/error.imageset/error.svg b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/error.imageset/error.svg new file mode 100644 index 0000000..1f2ed48 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/error.imageset/error.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/no_internet.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/no_internet.imageset/Contents.json new file mode 100644 index 0000000..2d9a9ce --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/no_internet.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "no_internet.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/no_internet.imageset/no_internet.svg b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/no_internet.imageset/no_internet.svg new file mode 100644 index 0000000..ef81f14 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image.xcassets/img/no_internet.imageset/no_internet.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Lottie/loti_spinner.lottie b/Projects/Shared/DesignSystem/Resources/Lottie/loti_spinner.lottie new file mode 100644 index 0000000..9882109 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Lottie/loti_spinner.lottie @@ -0,0 +1 @@ +{"nm":"loti_spinner","ddd":0,"h":40,"w":40,"meta":{"g":"LottieFiles Figma v67"},"layers":[{"ty":4,"nm":"Ellipse 6067","sr":1,"st":0,"op":73.24,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":54},{"s":[20,20],"t":72}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":54},{"s":[100,100],"t":72}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":54},{"s":[20,20],"t":72}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[90],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[180],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[270],"t":54},{"s":[360],"t":72}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":54},{"s":[100],"t":72}]}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[0.17,-1.37],[0.69,-1.65],[1.86,-1.86],[2.43,-1.01],[1.77,-0.22],[0,1.38],[0,0],[-1.36,0.23],[-1.05,0.43],[-1.39,1.39],[-0.75,1.82],[-0.19,1.11],[-1.38,0],[0,0]],"o":[[1.38,0],[-0.22,1.77],[-1.01,2.43],[-1.86,1.86],[-1.65,0.69],[-1.37,0.17],[0,0],[0,-1.38],[1.11,-0.19],[1.82,-0.75],[1.39,-1.39],[0.43,-1.05],[0.23,-1.36],[0,0],[0,0]],"v":[[37.5,20],[39.84,22.49],[38.48,27.65],[34.14,34.14],[27.65,38.48],[22.49,39.84],[20,37.5],[20,37.5],[22.49,34.79],[25.74,33.86],[30.61,30.61],[33.86,25.74],[34.79,22.49],[37.5,20],[37.5,20]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[0.17,-1.37],[0.69,-1.65],[1.86,-1.86],[2.43,-1.01],[1.77,-0.22],[0,1.38],[0,0],[-1.36,0.23],[-1.05,0.43],[-1.39,1.39],[-0.75,1.82],[-0.19,1.11],[-1.38,0],[0,0]],"o":[[1.38,0],[-0.22,1.77],[-1.01,2.43],[-1.86,1.86],[-1.65,0.69],[-1.37,0.17],[0,0],[0,-1.38],[1.11,-0.19],[1.82,-0.75],[1.39,-1.39],[0.43,-1.05],[0.23,-1.36],[0,0],[0,0]],"v":[[37.5,20],[39.84,22.49],[38.48,27.65],[34.14,34.14],[27.65,38.48],[22.49,39.84],[20,37.5],[20,37.5],[22.49,34.79],[25.74,33.86],[30.61,30.61],[33.86,25.74],[34.79,22.49],[37.5,20],[37.5,20]]}],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[0.17,-1.37],[0.69,-1.65],[1.86,-1.86],[2.43,-1.01],[1.77,-0.22],[0,1.38],[0,0],[-1.36,0.23],[-1.05,0.43],[-1.39,1.39],[-0.75,1.82],[-0.19,1.11],[-1.38,0],[0,0]],"o":[[1.38,0],[-0.22,1.77],[-1.01,2.43],[-1.86,1.86],[-1.65,0.69],[-1.37,0.17],[0,0],[0,-1.38],[1.11,-0.19],[1.82,-0.75],[1.39,-1.39],[0.43,-1.05],[0.23,-1.36],[0,0],[0,0]],"v":[[37.5,20],[39.84,22.49],[38.48,27.65],[34.14,34.14],[27.65,38.48],[22.49,39.84],[20,37.5],[20,37.5],[22.49,34.79],[25.74,33.86],[30.61,30.61],[33.86,25.74],[34.79,22.49],[37.5,20],[37.5,20]]}],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[0.17,-1.37],[0.69,-1.65],[1.86,-1.86],[2.43,-1.01],[1.77,-0.22],[0,1.38],[0,0],[-1.36,0.23],[-1.05,0.43],[-1.39,1.39],[-0.75,1.82],[-0.19,1.11],[-1.38,0],[0,0]],"o":[[1.38,0],[-0.22,1.77],[-1.01,2.43],[-1.86,1.86],[-1.65,0.69],[-1.37,0.17],[0,0],[0,-1.38],[1.11,-0.19],[1.82,-0.75],[1.39,-1.39],[0.43,-1.05],[0.23,-1.36],[0,0],[0,0]],"v":[[37.5,20],[39.84,22.49],[38.48,27.65],[34.14,34.14],[27.65,38.48],[22.49,39.84],[20,37.5],[20,37.5],[22.49,34.79],[25.74,33.86],[30.61,30.61],[33.86,25.74],[34.79,22.49],[37.5,20],[37.5,20]]}],"t":54},{"s":[{"c":true,"i":[[0,0],[0.17,-1.37],[0.69,-1.65],[1.86,-1.86],[2.43,-1.01],[1.77,-0.22],[0,1.38],[0,0],[-1.36,0.23],[-1.05,0.43],[-1.39,1.39],[-0.75,1.82],[-0.19,1.11],[-1.38,0],[0,0]],"o":[[1.38,0],[-0.22,1.77],[-1.01,2.43],[-1.86,1.86],[-1.65,0.69],[-1.37,0.17],[0,0],[0,-1.38],[1.11,-0.19],[1.82,-0.75],[1.39,-1.39],[0.43,-1.05],[0.23,-1.36],[0,0],[0,0]],"v":[[37.5,20],[39.84,22.49],[38.48,27.65],[34.14,34.14],[27.65,38.48],[22.49,39.84],[20,37.5],[20,37.5],[22.49,34.79],[25.74,33.86],[30.61,30.61],[33.86,25.74],[34.79,22.49],[37.5,20],[37.5,20]]}],"t":72}]}},{"ty":"gf","bm":0,"hd":false,"nm":"","e":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0.0000026964000880980166,20],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[5.392799948822358e-7,20],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[5.392799948822358e-7,20],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[5.392799948822358e-7,20],"t":54},{"s":[0.0000026964000880980166,20],"t":72}]},"g":{"p":2,"k":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0,1,1,1,1,1,1,1,0,0,1,1],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0,1,1,1,1,1,1,1,0,0,1,1],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0,1,1,1,1,1,1,1,0,0,1,1],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0,1,1,1,1,1,1,1,0,0,1,1],"t":54},{"s":[0,1,1,1,1,1,1,1,0,0,1,1],"t":72}]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[16.190500259399414,1.428570032119751],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[18.095199584960938,-6.906569751663483e-7],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[18.095199584960938,-6.906569751663483e-7],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[18.095199584960938,-6.906569751663483e-7],"t":54},{"s":[16.190500259399414,1.428570032119751],"t":72}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":54},{"s":[100],"t":72}]}}],"ind":1},{"ty":4,"nm":"Ellipse 6066","sr":1,"st":0,"op":73.24,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":54},{"s":[20,20],"t":72}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100,100],"t":54},{"s":[100,100],"t":72}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[20,20],"t":54},{"s":[20,20],"t":72}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[0],"t":54},{"s":[0],"t":72}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[100],"t":54},{"s":[100],"t":72}]}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[11.05,0],[0,11.05],[-11.05,0],[0,-11.05]],"o":[[0,11.05],[-11.05,0],[0,-11.05],[11.05,0],[0,0]],"v":[[40,20],[20,40],[0,20],[20,0],[40,20]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[11.05,0],[0,11.05],[-11.05,0],[0,-11.05]],"o":[[0,11.05],[-11.05,0],[0,-11.05],[11.05,0],[0,0]],"v":[[40,20],[20,40],[0,20],[20,0],[40,20]]}],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[11.05,0],[0,11.05],[-11.05,0],[0,-11.05]],"o":[[0,11.05],[-11.05,0],[0,-11.05],[11.05,0],[0,0]],"v":[[40,20],[20,40],[0,20],[20,0],[40,20]]}],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[11.05,0],[0,11.05],[-11.05,0],[0,-11.05]],"o":[[0,11.05],[-11.05,0],[0,-11.05],[11.05,0],[0,0]],"v":[[40,20],[20,40],[0,20],[20,0],[40,20]]}],"t":54},{"s":[{"c":true,"i":[[0,0],[11.05,0],[0,11.05],[-11.05,0],[0,-11.05]],"o":[[0,11.05],[-11.05,0],[0,-11.05],[11.05,0],[0,0]],"v":[[40,20],[20,40],[0,20],[20,0],[40,20]]}],"t":72}]}},{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[-8.28,0],[0,8.28],[8.28,0],[0,-8.28]],"o":[[0,8.28],[8.28,0],[0,-8.28],[-8.28,0],[0,0]],"v":[[5,20],[20,35],[35,20],[20,5],[5,20]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[-8.28,0],[0,8.28],[8.28,0],[0,-8.28]],"o":[[0,8.28],[8.28,0],[0,-8.28],[-8.28,0],[0,0]],"v":[[5,20],[20,35],[35,20],[20,5],[5,20]]}],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[-8.28,0],[0,8.28],[8.28,0],[0,-8.28]],"o":[[0,8.28],[8.28,0],[0,-8.28],[-8.28,0],[0,0]],"v":[[5,20],[20,35],[35,20],[20,5],[5,20]]}],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[{"c":true,"i":[[0,0],[-8.28,0],[0,8.28],[8.28,0],[0,-8.28]],"o":[[0,8.28],[8.28,0],[0,-8.28],[-8.28,0],[0,0]],"v":[[5,20],[20,35],[35,20],[20,5],[5,20]]}],"t":54},{"s":[{"c":true,"i":[[0,0],[-8.28,0],[0,8.28],[8.28,0],[0,-8.28]],"o":[[0,8.28],[8.28,0],[0,-8.28],[-8.28,0],[0,0]],"v":[[5,20],[20,35],[35,20],[20,5],[5,20]]}],"t":72}]}},{"ty":"fl","bm":0,"hd":false,"nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1,1,1],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1,1,1],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1,1,1],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1,1,1],"t":54},{"s":[1,1,1],"t":72}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[30],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[30],"t":18},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[30],"t":36},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[30],"t":54},{"s":[30],"t":72}]}}],"ind":2}],"v":"5.7.0","fr":60,"op":72.24,"ip":0,"assets":[]} \ No newline at end of file diff --git a/graph.png b/graph.png new file mode 100644 index 0000000..c6ac000 Binary files /dev/null and b/graph.png differ