Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SCRUM-50] ErrorFeature: 네트워크 에러뷰, 서버통신 에러뷰 UI #57

Merged
merged 8 commits into from
Nov 24, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ public enum Core: String, Modulable {
case UserDefaultsClient
case FeedbackGeneratorClient
case NetworkTracking
case StreamListener
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ public enum Feature: String, Modulable {
case MyPageFeature
case PomodoroFeature
case CatFeature
case ErrorFeature
}
2 changes: 1 addition & 1 deletion Projects/Core/APIClient/Interface/Model/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ServerState> = { .never }
Comment on lines +21 to +22
Copy link
Member

@devMinseok devMinseok Nov 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

목적에 종속되지 않도록 만드는게 좋을거 같아
Key(네트워크 에러 리스너)와 value(에러타입) 쌍으로 이루어진 dictionary를 갖게해서 (service locator 구현 하듯이)
send할때 key값과 value를 주고 receive 할때 key값을 넘겨서 그 key의 value만 옵저빙 하도록

좀 더 도움을 주자면... value에 AsyncStream을 가지고 있도록 해서 여러 stream을 지니도록 만들어서 key에 맞는 stream을 받도록 해야될거 같아

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런식으로 해야 앞으로 개선해야하는 토스트 메시지, 네트워크 오프라인 유무 등에도 적용하기 좋을거 같네

}

extension StreamListener: TestDependencyKey {
public static let previewValue = Self()
public static let testValue = Self()
}

public enum ServerState {
case requestStarted
case requestCompleted
case errorOccured
case networkDisabled
}
22 changes: 22 additions & 0 deletions Projects/Core/StreamListener/Project.swift
Original file line number Diff line number Diff line change
@@ -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)
]
]
)
46 changes: 46 additions & 0 deletions Projects/Core/StreamListener/Sources/StreamListener.swift
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 18 in Projects/Core/StreamListener/Sources/StreamListener.swift

View workflow job for this annotation

GitHub Actions / Run Swiftlint

Don't include vertical whitespace (empty line) after opening braces (vertical_whitespace_opening_braces)
// 네이밍 추천 plz .,
actor ContinuationActor {
var continuation: AsyncStream<ServerState>.Continuation?

func set(_ newContinuation: AsyncStream<ServerState>.Continuation) {
continuation = newContinuation
}

func yield(_ state: ServerState) {
continuation?.yield(state)
}
}

let continuationActor = ContinuationActor()
let asyncStream = AsyncStream<ServerState> { continuation in
Task { await continuationActor.set(continuation) }
}

return StreamListener(
sendServerState: { state in
await continuationActor.yield(state)
},
updateServerState: {
return asyncStream
}
)
}
}
12 changes: 12 additions & 0 deletions Projects/Core/StreamListener/Testing/StreamListenerTesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// StreamListenerTesting.swift
// StreamListener
//
// Created by <#T##Author name#> on 11/21/24.
//

import Foundation

public struct StreamListenerTesting {
public init() {}
}
33 changes: 33 additions & 0 deletions Projects/Core/StreamListener/Tests/StreamListenerTests.swift
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
52 changes: 45 additions & 7 deletions Projects/Feature/CatFeature/Sources/NamingCat/NamingCatCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CatServiceInterface
import DesignSystem
import UserServiceInterface
import DatabaseClientInterface
import StreamListenerInterface

import ComposableArchitecture
import RiveRuntime
Expand Down Expand Up @@ -41,6 +42,8 @@ public struct NamingCatCore {
case setTooltip(DownDirectionTooltip?)
case saveChangedCat(SomeCat)
case _setNextAction
case _postNamedCatRequest(ChangeCatNameRequest)
case _postNamedCatResponse(Result<Void, Error>)
case binding(BindingAction<State>)
}

Expand All @@ -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() {}
Expand Down Expand Up @@ -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:
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -148,3 +165,24 @@ public struct NamingCatCore {
return error
}
}

extension NamingCatCore {
private func handleError(error: any Error) -> EffectOf<NamingCatCore> {
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
}
}
}
69 changes: 55 additions & 14 deletions Projects/Feature/CatFeature/Sources/SelectCat/SelectCatCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import UserServiceInterface
import CatServiceInterface
import UserNotificationClientInterface
import DatabaseClientInterface
import StreamListenerInterface
import DesignSystem

import RiveRuntime
Expand Down Expand Up @@ -41,6 +42,8 @@ public struct SelectCatCore {
case _moveToNamingCat
case _fetchCatListRequest
case _fetchCatListResponse(Result<[Cat], Error>)
case _postSelectedCatRequest(SelectCatRequest)
case _postSelectedCatResponse(Result<Void, Error>)
case binding(BindingAction<State>)
case namingCat(PresentationAction<NamingCatCore.Action>)
}
Expand All @@ -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<Self> {
BindingReducer()
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -147,3 +166,25 @@ public struct SelectCatCore {
}
}
}

extension SelectCatCore {
// TODO: 다른 곳에서도 사용될 코드인데 따로 뺄 방법 ..
private func handleError(error: any Error) -> EffectOf<SelectCatCore> {
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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "ICON_DEMO.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Loading