Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions DevLog/App/DevLogApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ struct DevLogApp: App {

var body: some Scene {
WindowGroup {
RootView(viewModel: LoginViewModel(
signInUseCase: container.resolve(SignInUseCase.self),
signOutUseCase: container.resolve(SignOutUseCase.self),
sessionUseCase: container.resolve(AuthSessionUseCase.self)
))
RootView(
viewModel: RootViewModel(
sessionUseCase: container.resolve(AuthSessionUseCase.self),
signOutUseCase: container.resolve(SignOutUseCase.self)
))
.preferredColorScheme(theme.colorScheme)
}
}
Expand Down
41 changes: 19 additions & 22 deletions DevLog/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,24 @@
import SwiftUI

struct RootView: View {
@AppStorage("isFirstLaunch") var isFirstLaunch = true // 앱을 최초 설치했을 때 기존 로그인 세션이 남아있으면 자동 로그인됨을 막음
@StateObject var viewModel: LoginViewModel
@Environment(\.diContainer) var container: DIContainer
@StateObject var viewModel: RootViewModel

var body: some View {
ZStack {
Color(UIColor.systemGroupedBackground).ignoresSafeArea()
if let signIn = viewModel.state.signIn {
if signIn && !isFirstLaunch {
if signIn && !viewModel.state.isFirstLaunch {
MainView()
} else {
LoginView(viewModel: viewModel)
LoginView(viewModel: LoginViewModel(
signInUseCase: container.resolve(SignInUseCase.self),
signOutUseCase: container.resolve(SignOutUseCase.self),
sessionUseCase: container.resolve(AuthSessionUseCase.self))
)
.onAppear {
if isFirstLaunch {
isFirstLaunch = false
if viewModel.state.isFirstLaunch {
viewModel.send(.setFirstLaunch(false))
viewModel.send(.signOutAuto)
}
}
Expand All @@ -30,31 +34,24 @@ struct RootView: View {
Color.clear.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
if viewModel.state.signIn == nil {
isFirstLaunch = true
viewModel.send(.setFirstLaunch(true))
viewModel.send(.signOutAuto)
}
}
}
}
if viewModel.state.isLoading {
LoadingView()
}
}
.alert("네트워크 문제", isPresented: Binding(
get: { viewModel.state.showToast },
set: { _, _ in }
.alert(viewModel.state.alertTitle, isPresented: Binding(
get: { viewModel.state.showAlert },
set: { viewModel.send(.setAlert($0)) }
)) {
Button(role: .cancel, action: {
viewModel.send(.tapCloseToast)
}) {
Text("확인")
}
Button("확인", role: .cancel) { }
} message: {
Text(viewModel.state.toastMessage)
Text(viewModel.state.alertMessage)
}
.onChange(of: isFirstLaunch) { _ in
if isFirstLaunch {
isFirstLaunch = false
.onChange(of: viewModel.state.isFirstLaunch) { newValue in
if newValue {
viewModel.send(.setFirstLaunch(false))
viewModel.send(.signOutAuto)
}
}
Expand Down
2 changes: 1 addition & 1 deletion DevLog/Domain/UseCase/Auth/SignIn/SignInUseCaseImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ final class SignInUseCaseImpl: SignInUseCase {
}

func execute(_ provider: AuthProvider) async throws {
return try await repository.signIn(provider)
try await repository.signIn(provider)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ final class DeleteWebPageUseCaseImpl: DeleteWebPageUseCase {
try await repository.delete(urlString)
}
}

57 changes: 17 additions & 40 deletions DevLog/Infra/Service/NWPathConnectivityProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,58 +5,35 @@
// Created by 최윤진 on 11/2/25.
//

import Foundation
import Network
import Combine

final class NWPathConnectivityProvider {
private let networkPathMonitor = NWPathMonitor()
private let monitoringQueue = DispatchQueue(label: "NWPathConnectivityProviderQueue")

private var isConnectedValue: Bool
private var connectivityContinuations: [UUID: AsyncStream<Bool>.Continuation] = [:]

init() {
self.isConnectedValue = (networkPathMonitor.currentPath.status == .satisfied)

self.networkPathMonitor.pathUpdateHandler = { [weak self] path in
let connected = (path.status == .satisfied)
Task { @MainActor in
self?.handlePathStatusChange(isConnected: connected)
}
}
self.networkPathMonitor.start(queue: monitoringQueue)
}

deinit {
self.networkPathMonitor.cancel()
self.connectivityContinuations.values.forEach { $0.finish() }
self.connectivityContinuations.removeAll()
private let isConnectedSubject = CurrentValueSubject<Bool, Never>(false)

var isConnectedPublisher: AnyPublisher<Bool, Never> {
isConnectedSubject.eraseToAnyPublisher()
}

var isConnected: Bool {
self.isConnectedValue
isConnectedSubject.value
}

func connectivityStream() -> AsyncStream<Bool> {
let identifier = UUID()
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { [weak self] continuation in
guard let self else { return }
self.connectivityContinuations[identifier] = continuation
continuation.yield(self.isConnectedValue)
init() {
let initialStatus = networkPathMonitor.currentPath.status == .satisfied
isConnectedSubject.send(initialStatus)

continuation.onTermination = { [weak self] _ in
Task { @MainActor in
self?.connectivityContinuations.removeValue(forKey: identifier)
}
}
networkPathMonitor.pathUpdateHandler = { [weak self] path in
let connected = (path.status == .satisfied)
self?.isConnectedSubject.send(connected)
}
networkPathMonitor.start(queue: monitoringQueue)
}

private func handlePathStatusChange(isConnected: Bool) {
guard isConnected != self.isConnectedValue else { return }
self.isConnectedValue = isConnected
for continuation in self.connectivityContinuations.values {
continuation.yield(isConnected)
}
deinit {
networkPathMonitor.cancel()
isConnectedSubject.send(completion: .finished)
}
}
71 changes: 38 additions & 33 deletions DevLog/Presentation/ViewModel/LoginViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,18 @@ final class LoginViewModel: Store {
struct State {
var signIn: Bool?
var isLoading = false
var showToast: Bool = false
var toastMessage: String = ""
var showAlert: Bool = false
var alertTitle: String = ""
var alertMessage: String = ""
}

enum Action {
case signOutAuto
case tapCloseToast
case setAlert(Bool)
case tapSignInButton(AuthProvider)
case tapSignOutButton
case didStartLoading
case didFinishLoading
case didLogined(result: Bool)
case didLoginFail(message: String)
case setLoading(Bool)
case setLogined(Bool)
}

enum SideEffect {
Expand Down Expand Up @@ -54,65 +53,71 @@ final class LoginViewModel: Store {
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] signIn in
self?.send(.didLogined(result: signIn))
self?.send(.setLogined(signIn))
}
.store(in: &cancellables)
}

func reduce(with action: Action) -> [SideEffect] {
var state = self.state

switch action {
case .tapCloseToast:
state.showToast = false
case .setAlert(let isPresented):
setAlert(&state, isPresented: isPresented)
case .tapSignInButton(let authProvider):
self.state = state
return [.signIn(authProvider)]
case .tapSignOutButton, .signOutAuto:
self.state = state
return [.signOut]
case .didStartLoading:
state.isLoading = true
case .didFinishLoading:
state.isLoading = false
case .didLogined(let result):
case .setLoading(let value):
state.isLoading = value
case .setLogined(let result):
state.signIn = result
case .didLoginFail(let message):
state.toastMessage = message
state.showToast = true
}

self.state = state
return []
}

func run(_ effect: SideEffect) {
send(.setLoading(true))
switch effect {
case .signIn(let authProvider):
Task {
send(.didStartLoading)
do {
defer { send(.didFinishLoading) }

_ = try await self.signInUseCase.execute(authProvider)

send(.didFinishLoading)
send(.didLogined(result: true))
defer { send(.setLoading(false)) }
try await self.signInUseCase.execute(authProvider)
send(.setLogined(true))
sessionUseCase.execute(true)
} catch {
send(.didFinishLoading)
send(.didLogined(result: false))
send(.setLogined(false))
sessionUseCase.execute(false)
send(.didLoginFail(message: error.localizedDescription))
send(.setAlert(true))
}
}
case .signOut:
Task {
send(.didStartLoading)
do {
defer { send(.didFinishLoading) }
defer { send(.setLoading(false)) }
try await self.signOutUseCase.execute()
send(.didLogined(result: false))
send(.setLogined(false))
sessionUseCase.execute(false)
} catch {
send(.didFinishLoading)
send(.didLoginFail(message: error.localizedDescription))
send(.setAlert(true))
}
}
}
}
}

private extension LoginViewModel {
func setAlert(
_ state: inout State,
isPresented: Bool,
) {
state.alertTitle = "오류"
state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요."
state.showAlert = isPresented
}
}
Loading