Skip to content
Open
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
14 changes: 6 additions & 8 deletions Rephoto_iOS/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,25 @@
//

import SwiftUI
import Factory

struct ContentView: View {
@State private var loginVM: LoginViewModel

init(userProvider: UserUseCaseProviderProtocol) {
self._loginVM = State(initialValue: LoginViewModel(provider: userProvider))
}
@Injected(\.sessionStore) private var session

var body: some View {
Group {
if loginVM.isLoggedIn {
if session.isLoggedIn {
RephotoTabView()
} else {
LoginView(loginVM: loginVM)
LoginView(session: session)
}
}
.task { await session.restore() }
}
}

#if DEBUG
#Preview {
ContentView(userProvider: MockUserUseCaseProvider())
ContentView()
}
#endif
5 changes: 1 addition & 4 deletions Rephoto_iOS/App/Rephoto_iOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@
//

import SwiftUI
import Factory

@main
struct Rephoto_iOSApp: App {
@Injected(\.userUseCaseProvider) private var userProvider

var body: some Scene {
WindowGroup {
ContentView(userProvider: userProvider)
ContentView()
}
}
}
11 changes: 11 additions & 0 deletions Rephoto_iOS/Core/DIContainer/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ extension Container: @retroactive AutoRegistering {
}
}

// MARK: - Session

/// 앱 전역 세션. @MainActor 타입이므로 메인 스레드 해석을 전제로 한다 (SwiftUI 뷰 초기화 시점).
var sessionStore: Factory<SessionStore> {
self {
MainActor.assumeIsolated {
SessionStore(provider: self.userUseCaseProvider.resolve())
}
}.singleton
}

// MARK: - AutoRegistering

public func autoRegister() {
Expand Down
3 changes: 3 additions & 0 deletions Rephoto_iOS/Features/RephotoTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import PhotosUI
import Factory

struct RephotoTabView: View {
@Injected(\.sessionStore) private var session
@Injected(\.homeUseCaseProvider) private var homeProvider
@Injected(\.searchUseCaseProvider) private var searchProvider

Expand All @@ -24,6 +25,8 @@ struct RephotoTabView: View {
}
.tint(.green)
.tabBarMinimizeBehavior(.onScrollDown)
// 하위 탭(프로필/로그아웃 UI 등)이 @Environment(SessionStore.self)로 세션에 접근
.environment(session)
}
}

Expand Down
62 changes: 62 additions & 0 deletions Rephoto_iOS/Features/User/Presentation/Session/SessionStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// SessionStore.swift
// Rephoto_iOS
//
// Created by 김도연 on 7/2/26.
//

import SwiftUI

/// 앱 전역 인증/세션 상태를 소유한다.
/// 인증 동작(로그인/로그아웃/복원)을 담당하며 실패는 throw로 알린다.
/// 로딩/에러 같은 화면 표현 상태는 각 화면의 ViewModel이 가진다.
@Observable
@MainActor
final class SessionStore {
private let provider: UserUseCaseProviderProtocol

private(set) var isLoggedIn = false
private(set) var userInfo: UserInfo?
private(set) var name: String = "리포토"

init(provider: UserUseCaseProviderProtocol) {
self.provider = provider

provider.setOnRefreshFailed { [weak self] in
Task { @MainActor in
self?.forceLogout()
}
}
}

/// 앱 시작 시 저장된 토큰으로 세션 복원 (자동 로그인).
func restore() async {
guard await provider.hasTokens() else { return }
isLoggedIn = true
await refreshUser()
}

/// 자격 증명으로 로그인. 실패 시 throw.
func login(id: String, password: String) async throws {
try await provider.login().execute(loginId: id, password: password)
isLoggedIn = true
await refreshUser()
}

func logout() async {
try? await provider.logout().execute()
forceLogout()
}

/// 토큰 리프레시 실패 등으로 인한 강제 로그아웃 (로컬 상태만 정리).
func forceLogout() {
userInfo = nil
isLoggedIn = false
}

private func refreshUser() async {
guard let info = try? await provider.fetchUser().execute() else { return }
userInfo = info
name = info.name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import SwiftUI
@Observable
@MainActor
final class LoginViewModel {
let provider: UserUseCaseProviderProtocol
private let session: SessionStore

var loginId: String = ""
var password: String = ""
private(set) var isLoading = false
Expand All @@ -20,25 +20,9 @@ final class LoginViewModel {
get { errorMessage != nil }
set { if !newValue { errorMessage = nil } }
}
private(set) var isLoggedIn = false
private(set) var userInfo: UserInfo?
private(set) var name: String = "리포토"

init(provider: UserUseCaseProviderProtocol) {
self.provider = provider

provider.setOnRefreshFailed { [weak self] in
Task { @MainActor in
self?.forceLogout()
}
}
}

func onAppear() async {
if await provider.hasTokens() {
isLoggedIn = true
await fetchUser()
}
init(session: SessionStore) {
self.session = session
}

func login() async {
Expand All @@ -51,47 +35,11 @@ final class LoginViewModel {
errorMessage = nil

do {
try await provider.login().execute(loginId: loginId, password: password)
isLoggedIn = true
await fetchUser()
try await session.login(id: loginId, password: password)
} catch {
errorMessage = "로그인 실패: \(error.localizedDescription)"
}

isLoading = false
}

func fetchUser() async {
isLoading = true
errorMessage = nil

do {
let info = try await provider.fetchUser().execute()
userInfo = info
name = info.name
} catch {
errorMessage = "내 정보 조회 실패: \(error.localizedDescription)"
}

isLoading = false
}

func logout() async {
isLoading = true
errorMessage = nil

do {
try await provider.logout().execute()
} catch {
errorMessage = "로그아웃 실패: \(error.localizedDescription)"
}

forceLogout()
isLoading = false
}

func forceLogout() {
userInfo = nil
isLoggedIn = false
}
}
9 changes: 6 additions & 3 deletions Rephoto_iOS/Features/User/Presentation/Views/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import SwiftUI

struct LoginView: View {
@Bindable var loginVM: LoginViewModel
@State private var loginVM: LoginViewModel

init(session: SessionStore) {
self._loginVM = State(initialValue: LoginViewModel(session: session))
}

var body: some View {
ZStack {
Expand All @@ -26,7 +30,6 @@ struct LoginView: View {
Text(message)
}
}
.task { await loginVM.onAppear() }
}

// MARK: - Title
Expand Down Expand Up @@ -91,6 +94,6 @@ struct LoginView: View {

#if DEBUG
#Preview("Login") {
LoginView(loginVM: LoginViewModel(provider: MockUserUseCaseProvider()))
LoginView(session: SessionStore(provider: MockUserUseCaseProvider()))
}
#endif
77 changes: 77 additions & 0 deletions Rephoto_iOSTests/LoginViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// LoginViewModelTests.swift
// Rephoto_iOSTests
//
// 로그인 화면 ViewModel의 표현 상태 검증.
// - 입력 검증(빈 필드) 시 에러 메시지, 세션 호출 없음
// - 로그인 성공/실패 시 isLoading/errorMessage 상태 전이
//

import XCTest
@testable import Rephoto_iOS

@MainActor
final class LoginViewModelTests: XCTestCase {

private var provider: MockUserUseCaseProvider!
private var session: SessionStore!
private var sut: LoginViewModel!

override func setUp() async throws {
try await super.setUp()
provider = MockUserUseCaseProvider()
session = SessionStore(provider: provider)
sut = LoginViewModel(session: session)
}

override func tearDown() async throws {
sut = nil
session = nil
provider = nil
try await super.tearDown()
}

func test_login_emptyFields_setsValidationError_withoutCallingSession() async {
sut.loginId = ""
sut.password = "pw"

await sut.login()

XCTAssertEqual(sut.errorMessage, "아이디와 비밀번호를 입력해주세요.")
XCTAssertEqual(provider.loginCallCount, 0)
XCTAssertFalse(session.isLoggedIn)
}

func test_login_success_logsInWithoutError() async {
provider.fetchUserResult = .success(UserInfo(userId: 1, loginId: 100, name: "도연"))
sut.loginId = "dodle"
sut.password = "pw"

await sut.login()

XCTAssertNil(sut.errorMessage)
XCTAssertFalse(sut.isLoading)
XCTAssertTrue(session.isLoggedIn)
}

func test_login_failure_setsErrorMessage_andEndsLoading() async {
provider.loginResult = .failure(MockUserError.loginFailed)
sut.loginId = "dodle"
sut.password = "pw"

await sut.login()

XCTAssertNotNil(sut.errorMessage)
XCTAssertTrue(sut.isShowingError)
XCTAssertFalse(sut.isLoading)
XCTAssertFalse(session.isLoggedIn)
}

func test_isShowingError_setFalse_clearsErrorMessage() {
sut.errorMessage = "에러"

sut.isShowingError = false

XCTAssertNil(sut.errorMessage)
}
}
Loading