Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2081b17
[Feat] #202 - Data extension
OneTen19 Apr 2, 2026
97c1bd8
[Feat] #202 - AlbumSelectionFeature
OneTen19 Apr 2, 2026
933f914
[Feat] #202 - AlbumSelectionView
OneTen19 Apr 2, 2026
6272f28
[Feat] #202 - PhotoDetail 뷰에서 앨범 선택 뷰 연결
OneTen19 Apr 2, 2026
81a78ab
[Chore] #202 - 이미지 변환기능 릴리즈 Scheme에서는 숨김 처리
OneTen19 Apr 2, 2026
745ca25
[Feat] #202 - ArchiveImageFooter 상세화면, 선택화면 분기 추가
OneTen19 Apr 2, 2026
8ceb20a
[Chore] #202 - 변경된 ArchiveImageFooter 적용
OneTen19 Apr 2, 2026
3a092c4
[Style] #202 - 앨범으로 이동&복제 플로우 UI 구현
OneTen19 Apr 2, 2026
eb3c60b
[Feat] #202 - 앨범 삭제 버튼 추가
OneTen19 Apr 2, 2026
82b2b85
[Chore] #202 - 사진 가져오기 버튼 추가
OneTen19 Apr 2, 2026
72722a6
[Feat] #202 - 사진 불러오기 기능 구현
OneTen19 Apr 2, 2026
1b7c581
[Feat] #202 - 업로드된 사진들 보여주는 시트 무한스크롤 구현
OneTen19 Apr 2, 2026
e6abf7e
[Feat] #202 - 그라데이션 추가 및 앨범 변경 시 선택된 사진 삭제
OneTen19 Apr 2, 2026
f35b953
[Chore] #202- 사진 가져오기 시트 테두리 둥글기 수정
OneTen19 Apr 2, 2026
a0cefe0
[Feat] #202 - 앨범 선택 시 자연스러운 애니메이션 적용
OneTen19 Apr 2, 2026
b9646dc
[Feat] #202 - 전반적으로 자연스러운 애니메이션 적용
OneTen19 Apr 2, 2026
b5383b3
[Feat] #202 - 인스타그램 스토리 공유 기능 구현
OneTen19 Apr 2, 2026
ac403a6
Merge remote-tracking branch 'refs/remotes/origin/develop'
OneTen19 Apr 3, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,55 +7,141 @@

import SwiftUI

public enum PhotoSelectionPurpose {
case duplicate
case move
}

public enum ArchiveFooterStyle {
case detail // 사진 상세 화면 (즐겨찾기, 메모 아이콘)
case selection // 사진 선택 화면 (다운로드, 복제, 이동, 삭제 + 텍스트)
}

struct ArchiveImageFooter: View {

// MARK: - Properties

/// 버튼 활성화 여부 (선택 모드에서는 선택된 아이템 유무, 상세 모드에서는 항상 true)
let isEnabled: Bool
let style: ArchiveFooterStyle

/// 즐겨찾기 상태 (nil이면 버튼 숨김)
/// 버튼 활성화 여부
let isEnabled: Bool
/// 즐겨찾기 상태 (상세 모드 전용)
let isFavorite: Bool?

// 아이콘 액션
let onDownload: () -> Void
let onDelete: () -> Void
let onFavorite: (() -> Void)?
let onTapMemo: (() -> Void)?
let onDuplicate: (() -> Void)?
let onMove: (() -> Void)?

// MARK: - Init

public init(
style: ArchiveFooterStyle = .detail,
isEnabled: Bool = true,
isFavorite: Bool? = nil,
onDownload: @escaping () -> Void,
onDelete: @escaping () -> Void,
onFavorite: (() -> Void)? = nil,
onTapMemo: (() -> Void)? = nil
onTapMemo: (() -> Void)? = nil,
onDuplicate: (() -> Void)? = nil,
onMove: (() -> Void)? = nil
) {
self.style = style
self.isEnabled = isEnabled
self.isFavorite = isFavorite
self.onDownload = onDownload
self.onDelete = onDelete
self.onFavorite = onFavorite
self.onTapMemo = onTapMemo
self.onDuplicate = onDuplicate
self.onMove = onMove
}

// MARK: - Body

var body: some View {
if style == .selection {
selectionModeFooter
} else {
detailModeFooter
}
}
}

extension ArchiveImageFooter {
private var selectionModeFooter: some View {
HStack(alignment: .center, spacing: 0) {
selectionButton(
title: "다운로드",
icon: Image(isEnabled ? .iconDownloadFill : .iconDownload),
action: onDownload
)

selectionButton(
title: "사진 복제",
icon: Image(isEnabled ? .iconDuplicateFill : .iconDuplicate),
action: onDuplicate ?? {}
)

selectionButton(
title: "사진 이동",
icon: Image(isEnabled ? .iconMoveFill : .iconMove),
action: onMove ?? {}
)

selectionButton(
title: "삭제",
icon: Image(isEnabled ? .iconTrashFill : .iconTrash),
action: onDelete
)
}
.padding(.top, 8)
.padding(.bottom, 10)
.background(.white)
.overlay(
Rectangle()
.frame(height: 1)
.foregroundColor(.gray75),
alignment: .top
)
}

// 버튼 공통 뷰 빌더
@ViewBuilder
private func selectionButton(title: String, icon: Image, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 4) {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundStyle(isEnabled ? .gray700 : .gray200)

Text(title)
.nekiFont(.body14Medium)
.foregroundStyle(isEnabled ? .gray700 : .gray400)

}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.disabled(!isEnabled)
}

private var detailModeFooter: some View {
HStack(alignment: .center, spacing: 0) {
Button(action: onDownload) {
Image(isEnabled ? .iconDownloadFill : .iconDownload)
.renderingMode(.template)
.foregroundStyle(isEnabled ? .gray700 : .gray100)
.foregroundStyle(isEnabled ? .gray700 : .gray200)
}
.disabled(!isEnabled)

if let isFavorite = isFavorite, let onFavorite = onFavorite {
Button(action: onFavorite) {
Image(isFavorite ? .iconHeart28Fill : .iconHeart28Gray)
.renderingMode(.template)
.foregroundStyle(isFavorite ? .red : .gray700)
}
.padding(.leading, 16)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Date+.swift
// Neki-iOS
//
// Created by OneTen on 4/2/26.
//

import Foundation

extension Date {
/// Date를 "yyyy.MM.dd" 형태의 문자열로 변환합니다.
func toDotFormatString() -> String {
return DateFormatters.dotFormat.string(from: self)
}
}

private struct DateFormatters {
static let dotFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy.MM.dd"
formatter.locale = Locale(identifier: "ko_KR")
return formatter
}()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//
// AlbumSelectionFeature.swift
// Neki-iOS
//
// Created by OneTen on 4/2/26.
//

import ComposableArchitecture
import Foundation

@Reducer
struct AlbumSelectionFeature {
@ObservableState
struct State {
var albums: IdentifiedArrayOf<AlbumItem> = []
var selectedAlbumId: Int? = nil
var uploadCount: Int
var isFetching: Bool = false

// 앨범 생성관련
var newAlbumTitle: String = ""
var albumTitleErrorMessage: String? = nil

var isConfirmButtonEnabled: Bool {
return !newAlbumTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && albumTitleErrorMessage == nil
}

init(uploadCount: Int = 1) {
self.uploadCount = uploadCount
}
}

enum Action: BindableAction {
case binding(BindingAction<State>)

// 생명주기 및 네트워크
case onAppear
case fetchFavoriteAlbumResponse(Result<AlbumItem, Error>)
case fetchAlbumsResponse(Result<[AlbumItem], Error>)

// 사용자 액션
case tapBack
case tapAlbum(Int)
case tapConfirm

// 앨범 생성 액션
case onTapCancelAddAlbum
case onTapConfirmAddAlbum
case addFolderResponse(Result<Int, Error>)

case delegate(DelegateAction)
enum DelegateAction {
case didSelectAlbum(albumId: Int)
case didTapCancel
case showToast(NekiToastItem)
}
}

@Dependency(\.archiveClient) var archiveClient

var body: some ReducerOf<Self> {
BindingReducer()

Reduce { state, action in
switch action {
case .onAppear:
state.isFetching = true
return .merge(
.run { send in
do {
let entity = try await archiveClient.getFavoriteAlbumInfo()
let favoriteAlbum = AlbumItem(id: -1, title: "즐겨찾기", count: entity.totalCount, coverImageURL: URL(string: entity.latestImageURL), isFavorite: true)
await send(.fetchFavoriteAlbumResponse(.success(favoriteAlbum)))
} catch {
await send(.fetchFavoriteAlbumResponse(.failure(error)))
}
},
.run { send in
await send(.fetchAlbumsResponse(Result {
let entities = try await archiveClient.getAlbumList()
return entities.map { AlbumItem(id: $0.id, title: $0.name, count: $0.photoCount, coverImageURL: URL(string: $0.coverImageURLString), isFavorite: false) }
}))
}
)

case let .fetchFavoriteAlbumResponse(.success(album)):
state.albums.removeAll(where: { $0.isFavorite })
state.albums.insert(album, at: 0)
return .none

case .fetchFavoriteAlbumResponse(.failure):
return .none

case let .fetchAlbumsResponse(.success(fetchedAlbums)):
state.isFetching = false
let favorite = state.albums.first(where: { $0.isFavorite })
var newAlbums: [AlbumItem] = []
if let fav = favorite { newAlbums.append(fav) }
newAlbums.append(contentsOf: fetchedAlbums)
state.albums = IdentifiedArray(uniqueElements: newAlbums)
return .none

case .fetchAlbumsResponse(.failure):
state.isFetching = false
return .none

case .tapBack:
return .send(.delegate(.didTapCancel))

case let .tapAlbum(id):
// 즐겨찾기 앨범은 선택 불가하게
guard id != -1 else { return .none }

if state.selectedAlbumId == id {
state.selectedAlbumId = nil
} else {
state.selectedAlbumId = id
}
return .none

case .tapConfirm:
guard let id = state.selectedAlbumId else { return .none }
return .send(.delegate(.didSelectAlbum(albumId: id)))

// MARK: - 앨범 생성 관련 처리
case .onTapCancelAddAlbum:
state.newAlbumTitle = ""
state.albumTitleErrorMessage = nil
return .none

case .onTapConfirmAddAlbum:
guard state.isConfirmButtonEnabled else { return .none }
let title = state.newAlbumTitle.trimmingCharacters(in: .whitespacesAndNewlines)
state.newAlbumTitle = ""
state.albumTitleErrorMessage = nil

return .run { send in
await send(.addFolderResponse(Result {
try await archiveClient.addFolder(name: title)
}))
}

case .addFolderResponse(.success):
return .merge(
.send(.delegate(.showToast(NekiToastItem("새로운 앨범을 추가했어요", style: .success)))),
.send(.onAppear) // 앨범 목록 갱신
)

case .addFolderResponse(.failure):
return .send(.delegate(.showToast(NekiToastItem("앨범을 만들지 못했어요", style: .error))))

case .binding(\.newAlbumTitle):
let inputTitle = state.newAlbumTitle.trimmingCharacters(in: .whitespacesAndNewlines)
if state.albums.contains(where: { $0.title == inputTitle }) {
state.albumTitleErrorMessage = "이미 사용 중인 앨범명이에요."
} else {
state.albumTitleErrorMessage = nil
}
return .none

case .binding, .delegate:
return .none
}
}
}
}
Loading